Version vom 18. Mai 2020
Diese Einführung in R is inspiriert von und basiert zum Teil auf Arbeiten von Rudolf Farys, Ramnath Vaidyanathan, Iegor Rudnytskyi, Hadley Wickham, Garrett Grolemund, Selva Prabhakaran, Matt w. Loftis, Julia Silge, David Robinson, Andreas Niekler, Gregor Wiedemann und anderen Personen aus der R-Community.
R ist eine freie Programmiersprache und Softwareumgebung für statistische Berechnungen, das Erstellen von Grafiken und sehr vielem mehr (vgl. What is R?). R ist open source (GNU GPL) und plattformunabhängig. Entwickelt wird R von einem Kernteam und unzähligen Leuten aus der Community, die Packages beisteuern.
Website des R Projects for Statistical Computing: https://www.r-project.org/
2+3 und ENTER4-3 und CTRL+Rq() (oder X oben rechts/links)Praktischer ist die Verwendung von R mit RStudio, einer integrierten Entwicklungsumgebung (IDE) speziell für R. RStudio bietet eine bessere Übersicht (mehr Fenster), verschiedene Hilfen (z.B. Syntaxhervorhebung), Unterstützung für weitere Formate (z.B. R Markdown für Dokumente und Präsentationen) sowie eine Integration von Versionsverwaltungsprogrammen (z.B. Git). RStudio ist ebenfalls open source und für nicht-kommerzielle Anwendungen kostenlos. Meistens wird R daher mit RStudio verwendet.
2*3 und ENTER4/3 und CTRL+ENTERFür die Arbeit mit RStudio gibt es ein hilfreiches Cheat Sheet.
R verlangt ein Working directory. Das ist der Ordner (ausserhalb von R), der standardmässig zum Lesen und Schreiben von Dateien verwendet wird.
getwd(): abfragen des aktuellen Working directorysetwd("Pfad"): festlegen eines bestimmten Working directory, z.B. setwd("C:/Users/Ueli/Desktop/Proseminar_Automatisierte-Inhaltsanalyse-mit-R")
/ statt \R kennt alle gängigen arithmetischen und logischen Operatoren sowie zahlreiche weitere mathematische Funktionen.
+, -, *, /, ^&, |, ==, !=, <, >, <=, >=exp(x), log(x), log10(x), sin(x), cos(x), sqrt(x)# Alles, was in einer Zeile nach einem # steht, wird von R ignoriert
# Kommentare beginnen daher immer mit einem #
# Eine Zeile Code kann mit CTRL+ENTER ausgeführt werden
# oder indem man sie markiert und dann oben rechts auf das Icon "Run" klickt
# Arithmetische Operatoren
(15 + 7) * 4
(13 - 5) / (3 - 1)
# Logische Operatoren
2 == 4 / 2
2 >= 4 / 3
2 < 1
2 != 0
# Mathematische Funktionen
sqrt(4)
exp(log(2))
# Kombinationen
4 == (3 - 1) * 2 & 2 > log10(2)
0 != 5 | 0 < sin(5)
Hilfe bei der Arbeit mit Funktionen:
help.start(): allgemeine Hilfe zum Verwenden von Rhelp.search("Suchwort"): Durchsuchen der R-Hilfe nach einem bestimmten Suchwort, z.B. einer Funktion help.search("log")help(Funktion) oder ?Funktion: Anzeigen der R-Hilfe für die angegebene Funktion, z.B. ?logexample(Funktion): Der Beispiel-Code aus der R-Hilfe für die angegebene Funktion wird ausgeführt, z.B. example(log)round().# 2. Berechnungen
(23 + 5) / sqrt(6 + 2)
log2(4 * 12)
cos(sin(5)) ^ (2 + 3)
27 / -7 + sqrt(log(3))
# 3. Vergleiche
15 == (17 - 12) * 3
5 < 10 & 10 > 11
sqrt(49) != 7 ^ 2
# 4. Runden
round(4272943 / 1360120, digits = 4)
Dank Objekten und Funktionen kann R allerdings sehr viel mehr als ein mittelmässiger Taschenrechner. Dabei sollte alles, was in R existiert als Objekt verstanden werden und alles was passiert als Funktion (wobei eine Funktion immer auch ein Objekt ist).
Objekte verfügen über Eigenschaften, die bestimmen, was für Manipulationen möglich sind. Zudem können sie über Attribute verfügen, zum Beispiel Namen, Dimensionen (z.B. bei Matrizen), Länge (z.B. bei Vektoren) oder die Objektklasse.
Die Objektklasse sagt uns viel darüber, was wir mit einem Objekt machen können (oder nicht). R kennt fünf “atomische” Objektklassen:
"abc", "x"2, 14.32L (ohne L ist es numeric/double)TRUE, FALSE1+4iWelcher Klasse ein Objekt angehört, kann mit der Funktion class() oder typeof() abgefragt werden. Einige Objektklassen lassen sich zudem in andere überführen, z.B. as.integer(2).
Ein entscheidendes Feature von R ist, dass Objekte nicht immer in ihrer Ganzheit beschrieben werden müssen, sondern im (Global) Environment abgelegt werden können. Danach kann jederzeit auf sie zurückgegriffen werden. Wiederverwendbare Objekte können mit Hilfe eines Pfeils <- erzeugt werden, also beispielsweise a <- 10. Um einzelne Objekte aus dem Environment zu löschen, können wir die Funktion rm() verwenden. Um das gesamte Environment zu leeren, kann die Funktion rm(list = ls()) verwendet werden.
# Klasse eines Objekts abfragen
class(12)
typeof(12)
# Ein wiederverwendbares Objekt erzeugen
x <- 12
x
# seine Klasse abfragen
class(x)
# x transformieren
x <- as.integer(x)
class(x)
# x aus dem Global Environment löschen
rm(x)
y, das den Wert 12 + 3 hat. Welche Klasse hat y?z, das den Wert "R ist grossartig!" hat. Welche Klasse hat z? Und was gibt dir length(z) zurück?# 1. Objekt y
y <- 12 + 3
class(y)
# 2. Objekt z
z <- "R ist grossartig!"
class(z)
length(z)
In R werden drei verschiedene Arten von Klammern verwendet: (), [] and {}. Bisher haben wir nur runde () Klammern verwendet. Im Folgenden werden wir jedoch auch eckige [] und geschweifte {} Klammern brauchen. Bevor du konfus wirst, hier eine kurze Erklärung, wann welche Klammern verwendet werden müssen.
Runde () Klammern werden gebraucht, um Argumente an Funktionen zu übergeben. Wie du oben gesehen hast, benötigt die Funktion length() ein Argument, zum Beispiel length(z).
Im folgenden Kapitel werden verschiedene komplexere Datenstrukturen (sogenannte Datentypen) eingeführt. Dabei werden wir eckige [] Klammern brauchen, um auf Elemente innerhalb solcher Datenstrukturen zuzugreifen. Um beispielsweise die ersten fünf Elemente aus einem Vektor vec zu extrahieren, muss vec[1:5] eingegeben werden. Du wirst auch doppelte eckige [[]] Klammern kennenlernen, wenn wir gleich über Listen sprechen. Ihr Zweck ist derselbe: der direkte Zugriff auf eine Auswahl von Elementen. Du wirst im entsprechenden Abschnitt sehen, warum wir bei Listen [[]] und nicht [] verwenden.
Schließlich werden {}-Klammern bei if-else-Anweisungen, Schleifen und beim Schreiben eigener Funktionen verwendet. Auch das werden wir uns weiter unten noch genauer anschauen.
Auf diesen Objektklassen aufbauend, kennt R verschiedene Datentypen. Sie lassen sich nach Anzahl möglicher Dimensionen sowie ihrer Homogenität (nur Elemente der gleichen Klasse) bzw. Heterogenität (verschiedene Klassen möglich) ordnen.
| Homogen | Heterogen | |
|---|---|---|
| 1D | Vektor | Liste |
| 2D | Matrix | Data Frame |
| nD | Array | |
Ein Spezialfall sind Faktoren. Dabei handelt es sich um Vektoren mit Attributen, die kategoriale Daten abbilden. Diese sind als Zahlenwerte gespeichert (Vektor), die mit einem Label verbunden sind (Attribut).
Der einfachste Datentyp in R sind Vektoren. Es gibt sie in zwei Arten: “atomische” Vektoren (Vektoren) und Listen. Der Unterschied besteht darin, dass “atomische” Vektoren nur Elemente der gleichen Objektklasse enthalten können, Listen hingegen unterschiedliche (inklusive mehrdimensionale).
Entsprechend der Objektklassen kennt R verschiedene Typen von Vektoren:
# numeric/double
num_vec <- c(1, 1.2, 4.5)
class(num_vec)
typeof(num_vec)
is.numeric(num_vec)
is.double(num_vec)
is.integer(num_vec)
# integer
int_vec <- c(1L, 6L, 10L)
is.numeric(int_vec)
is.double(int_vec)
is.integer(int_vec)
# logical
log_vec <- c(TRUE, FALSE, TRUE)
# character
chr_vec <- c("R", "ist", "grossartig!")
length(chr_vec)
Vektoren können auf sehr unterschiedliche Art produziert und mit Attributen versehen werden:
# Verkettung von verschiedenen Objekten (concatenate)
vec <- c(1, 2, 3, 4, 5)
# mit Hilfe von Sequenzen
vec <- 1:5
vec <- seq(1, 5)
# einer Kombination von beidem
vec <- c(1:4, 5)
# als Resultat von Operationen
vec <- vec > 3
# oder als Extrakt eines anderen Vektors
letters
vec <- letters[1:5]
Eine nützliche Sache für die Datenbereinigung und Datenmanipulation ist das bedingte Ersetzen von Werten in Datenobjekten. Dazu muss der bekannte Code für die Zuweisung eines Wertes an ein Objekt (z.B. x <- 1) modifiziert werden, indem er um die Bedingung, unter der ein Wert geändert wird, ergänzt wird. Diese Bedingung wird in eckigen Klammern direkt nach dem Namen der Objekts hinzugefügt, also beispielsweise x[x > 0] <- 1. Hier weisen wir x den Wert 1 zu, wenn x grösser als 0 ist.
Angenommen, du hast zwei Vektoren: der erste gibt Auskunft darüber, ob eine Person studiert (TRUE oder FALSE). Der zweite Vektor enthält Informationen zur Art des Studiums der gleichen Personen (BA/MA/PhD/Not a student). Bei der Erhebung ist jedoch ein Fehler passiert und Doktorierende wurden als Nicht-Studierende eingetragen. Wir können das folgendermassen korrigieren:
student <- c(TRUE, TRUE, TRUE, FALSE, TRUE, FALSE, FALSE)
studtype <- c("BA", "MA", "BA", "PhD", "BA", "Not", "PhD")
# FALSE mit TRUE ersetzten, wenn jemand "PhD" ist. Es geht in einer Zeile!
student[studtype == "PhD"] <- TRUE
m <- 5:10 und n <- 15:20 zu einem Vektor o. Wie lange ist o?rep() ein numerischer Vektor x mit der Länge 15, der die Sequenz c(1, 2, 3, 4, 5) dreimal wiederholt.x die Elemente a, b, c, d hinzu. Welche Objektklasse haben die Elemente in x nun?num <- c(11, 1, 4, 12, 8, 1, 6, 10, 13, 4) alle Werte, die grösser als 10 sind durch NA.# 1. Vektoren verbinden
m <- 5:10
n <- 15:20
o <- c(m, n)
length(o)
# 2. Vektor mit rep()
x <- rep(1:5, 3)
# 3. Hinzufügen von Elementen
x <- c(x, c("a", "b", "c", "d"))
class(x)
# 4. Ersetzen von Werten
num <- c(11, 1, 4, 12, 8, 1, 6, 10, 13, 4)
num[num > 10] <- NA
Ein Vektor kann mit Attributen versehen werden. Attribute sind beispielsweise Namen oder Kommentare.
# Namen zuweisen
vec <- 1:4
names(vec)
names(vec) <- c("a", "b", "c", "d")
vec
# die Vergabe von Namen ist auch direkt möglich
vec <- c(a = 1, b = 2, c = 3, d = 4)
vec
# andere Attribute
attr(vec, "Kommentar") <- "Das ist ein schöner Vektor."
attributes(vec)
Wie bereits erwähnt, handelt es sich bei Faktoren um einen speziellen Typ von Vektoren. Faktoren werden gebraucht, um kategoriale Daten zu speichern.
# Faktor mit Ausprägungen "schlecht" und "gut" erzeugen
gs <- factor(c(0, 1, 0, 0, 1, 1, 0), labels = c("schlecht", "gut"))
gs
class(gs)
attributes(gs)
# Vorsicht bei der Transformation von Faktoren!
x <- c(20, 20, 10, 40, 10)
x
xf <- as.factor(x)
xf
x <- as.numeric(xf)
x
# Die Transformation ist nur über einen Umweg möglich
x <- as.numeric(as.character(xf))
x
Möchten wir Daten aus Vektoren entnehmen, gibt es verschiedene Möglichkeiten.
# Vektor erzeugen
vec <- letters[1:20]
# das erste Element von vec
vec[1]
# die ersten fünf Elemente von vec
vec[1:5]
# alle Elemente von vec ausser das fünfte, achte und zwölfte Element
vec[-c(5, 8, 12)]
# alle Elemente von vec, die nach d, aber vor o kommen
vec[vec > "d" & vec < "o"]
# mittels Name der Elemente
names(vec) <- LETTERS[1:20]
vec[names(vec) %in% c("A", "E", "I", "O")]
# mittels Label/Level bei Faktoren
gs <- factor(c(0, 1, 0, 0, 1, 1, 0), labels = c("schlecht", "gut"))
gs[gs == "gut"]
y, der die letzten vier Elemente von l <- letters[1:15] enthält. Findest du eine Möglichkeit, den Code so zu schreiben, dass er auch bei einer anderen Länge von l das gewünschte Resultat (d.h. die letzten vier Elemente) liefern würde. Tipp: hilf dir mit der Funktion length().plz <- c(3003, 4012, 4004, 3007, 4027, 3019, 4053, 3027) alle 30xx durch bern und alle 40xx durch basel. Vorsicht bei der Manipulation mit unterschiedlichen Objektklassen!kafiklatsch mit einem Espresso, drei Kafis, drei Schale und zwei Cappuccino.c(1, 2, 3) + c(7, 8, 9) rechnest? Und was, wenn c(1, 2) + c(7, 8, 9)?# 1. Die letzten vier Elemente von x
l <- letters[1:15]
# Lösung, die nur bei einer Länge von 15 funktioniert
length(l)
y <- l[11:15]
# Lösung, die bei jeder Länge von l funktioniert
y <- l[(length(l)-3):length(l)]
# 2. Postleitzahlen umcodieren
plz <- c(3003, 4012, 4004, 3007, 4027, 3019, 4053, 3027)
plz_chr <- as.character(plz)
plz_chr[plz < 4000] <- "bern"
plz_chr[plz >= 4000] <- "basel"
# 3. Faktor
kafiklatsch <- factor(c(1, rep(2, 3), rep(3, 3), rep(4, 2)), labels = c("Espresso", "Kafi", "Schale", "Cappuccino"))
# 4. Rechnen mit Vektoren
c(1, 2, 3) + c(7, 8, 9) # funktioniert
c(1, 2) + c(7, 8, 9) # funtkioniert nicht
Listen unterscheiden sich von atomischen Vektoren, da sie Objekte unterschiedlicher Klassen enthalten können (inklusive Listen). Listen werden mittels der Funktion list() erstellt.
x <- list(numeric = 1:15, character = c("abc", "def"), logical = c(TRUE, FALSE, TRUE),
list_in_list = list(another_character = "ghi", integer = c(15L, 17L, 28L)))
class(x)
# In eine Liste hineinblicken
str(x)
# Mit Teilen einer Liste arbeiten
x[1:3]
# Elemente aus einer Liste entnehmen
x[[1]]
x[[5]][[1]]
# oder mit Name (wenn Elemente Namen haben)
x$character
x$list_in_list$another_character
# Elemente aus Listenelementen entnehmen (abhängig von der Objektklasse des Listenelements)
# z.B. bei einem Vektor
x[[1]][1:5]
x$numeric[1:5]
Die meisten Funktion sind für die Verarbeitung von Vektoren/Listen optimiert. Wenn möglich, sollte deshalb mit vektorisierten Daten gearbeitet werden. Dies ist in der Regel effizienter als alternative Datenformate, da es einerseits weniger Zeilen Code braucht und die Performance besser ist (d.h. Berechnungen werden schneller ausgeführt).
a, die folgende Vektoren enthält: c("l", "m", "n", "o", "p"), seq(7, 70, by = 7), c(FALSE, TRUE, TRUE) und 20:30.a in der Mitte in zwei neue Listen b und c.b den zweiten Vektor. Was ist seine Objektklasse?# 1. Liste erstellen
a <- list(c("l", "m", "n", "o", "p"), seq(7, 70, by = 7), c(FALSE, TRUE, TRUE), 20:30)
# 2. Liste teilen
b <- a[1:2]
c <- a[3:4]
# 3. Element entnehmen
d <- b[[2]]
class(d)
Werden Vektoren um ein dim-Attribut ergänzt, verhalten sie sich wie ein multidimensionales Array. Bei einem Array mit genau zwei Dimensionen handelt es sich um eine Matrix.
# Erstellen eines Arrays
a <- 1:6
dim(a) <- c(3, 2)
a
# Eine Infos abfragen
class(a)
is.matrix(a)
attributes(a)
dim(a)
nrow(a)
ncol(a)
length(a)
# Erstellen einer Matrix durch verbinden von Vektoren
# für Arrays mit mehr Dimensionen: abind() im abind-Package
b <- cbind(1:3, 4:6)
c <- rbind(c(1, 4), c(2, 5), c(3, 6))
class(c)
# oder direkt mit matrix-Funktion
d <- matrix(1:6, ncol = 2, nrow = 3)
class(d)
e <- array(1:12, c(2,3,2))
class(e)
Die Entnahme von Daten funktioniert gleich wie bei Vektoren. Zudem können wir Spalten- und Zeilennamen vergeben, über die wir die “Zellen” ebenfalls ansteuern können.
m <- matrix(letters[1:12], ncol = 3, nrow = 4)
rownames(m) <- paste0("r_", 1:4)
colnames(m) <- paste0("c_", 1:3)
# Merke: immer zuerst die Zeile(n), dann die Spalte(n)!
# ein einzelner Wert
m[2, 2]
m["r_2", "c_2"]
m[2, "c_2"]
# eine Zeile
m[2, ]
m["r_2", ]
# eine Spale
m[, 2]
m[, "c_2"]
# ein Ausschnitt
m[1:2, 2:3]
m[c("r_1", "r_2"), c("c_2", "c_3")]
# Bei Arrays funktioniert es genau gleich
e <- array(letters[1:12], c(2,3,2))
dimnames(e) <- list(paste0("r_", 1:2), paste0("c_", 1:3), paste0("m_", 1:2))
e[1:2, 2, 2]
e[c("r_1", "r_2"), "c_2", "m_2"]
dim(), wenn wir es auf einen Vektor anwenden?is.matrix(x) gleich TRUE ist, was ist dann is.array(x)?seq() einen Vektor ev, der alle geraden Zahlen zwischen 1 und 70 enthält. Kontrolliere, ob es geklappt hat.ev eine Matrix ev_mat mit dem Dimensionen 6 x 6. Falls der Code zu einer Warnmeldung führt, woran könnte das liegen?ev_mat den Bereich aus, dessen Einträge grösser/gleich 24 sind. Speichere ihn im Objekt v_mat. Was für eine Objektklasse hat das neue Objekt? Und welche Dimensionen?v_mat eine Matrix n_mat der Dimensionen 8 x 3. Wir merken, dass Zeilen und Spalten vertauscht sind - stelle mit Hilfe der Funktion t() die richtige Ordnung her und speichere die Matrix als Objekt t_mat.t_mat die Zeilen 4 bis 8 hinzu, die die Zahlen 1 bis 40 enthalten. Speichere die neue Matrix als an_mat.an_mat.an_mat sollen alle Werte grösser/gleich 50 durch 22 ersetzt werden.an_mat:
%in%-Funktion.an_mat ist Teil einer Beobachtungsreihe mit drei Zeitpunkten.
# 1. Dimension von Vektor
vec <- 1:5
dim(vec)
# 2. Matrizen und Arrays
x <- matrix(1:6, ncol = 2, nrow = 3)
is.matrix(x)
is.array(x)
# 3. Sequenz gerader Zahlen
# Startpunkt (2) und Endpunkt (70) festlegen sowie die Schrittfolge der Sequenz (2)
ev <- seq(2,70,2)
# Beginn und Schluss des Resultats anzeigen lassen
head(ev)
tail(ev)
# Resultat kontrollieren: Länge des Vektors muss 35 sein
length(ev)
# 4. Vektor zu Matrix
ev_mat <- matrix(data = ev, nrow = 6, ncol = 6)
# Die Matrix hat eine Zelle mehr (36) als der Vektor Einträge hat (35). R "rezykliert"" den Vektor, d.h. die Matrix wird mit den exisiterenden Vektoreinträgen aufgefüllt, wobei R wieder von vorne beginnt. Der letzte Eintrag der Matrix ist daher 2.
# 5. Subset einer Matrix
v_mat <- ev_mat[ev_mat >= 24] # Subset der Ursprünglichen Matrix
class(v_mat) # Objektklasse
dim(v_mat) # Objektdimensionen
length(v_mat) # Länge
# Die Auswahlprozedere hat dazu geführt, dass die Matrix in einen Vektor umgewandelt wurde. Daher ist die Objektklasse nun "numeric", die Dimension "NULL" und die Länge 24.
# 6. Matrix erstellen und transformieren
# Zuerste erstellen wir aus v_mat eine neue Matrix mit den Dimensionen 8 x 3
n_mat <- matrix(v_mat, 8, 3)
t_mat <- t(n_mat)
# Dann transponieren wir die Matrix mit t()
t_mat <- t(n_mat)
# 7. Neue Daten einfügen
# Zuerste erstellen wir eine Matrix, die gleich viele Spalten (8) und Zeilen (5) hat wie t_mat. Wir füllen sie mit den die Zahlen 1 bis 40.
a_mat <- matrix(1:40, 5, 8)
# Dann verbinde wir beide Matrizen mit der Funktion rbind() - d.h. binden die Spalten untereinander
an_mat <- rbind(a_mat, t_mat)
# 8. Einträge entfernen
an_mat <- an_mat[-6,-5]
# 9. Einträge ersetzen
# Wir zählen die Anzahl der zu transformierenden Werte und der bereits vorhandenen Werte = 22
sum(an_mat >= 50) # Länge des Vektors der zu ersetzenden Werte
sum(an_mat == 22) # Kommt der Wert in der Matrix 22 überhaupt vor - und wenn ja, wie oft?
# Dann ersetzen wir die Werte
an_mat[an_mat >= 50] <- 22
# Dann überpfüfen wir, ob alles geklappt hat
sum(an_mat >= 50) # Das Resultat muss 0 sein
sum(an_mat == 22) # Das Resultat muss der Summe der zuvor gezählten Werte entsprechen
# 10. Unterschiedliche Einträge gleichzeitig ersetzen
# Hier brauchen wir ein anderes Vorgehen, die "%in%" Funktion, um mehrere vorhandene Einträge gleichzeitig zu zählen
sum(an_mat %in% c(40, 42, 44)) # es sind nicht drei, sondern vier Ersetzungen, die wir vornehmen müssen
# Dann ersetzen wir die Werte, wieder mit Hilfe der "%in%" Funktion
an_mat[an_mat %in% c(40, 42, 44)] <- 17
# Dann überpfüfen wir, ob alles geklappt hat
sum(an_mat %in% c(40, 42, 44)) # Das Resultat muss jetzt 0 sein
sum(an_mat == 17) # Das Resultat muss der Summe der zuvor gezählten Werte entsprechen
# 11. Von einer einzelnen Matrix zu Listen von Matrizen
# Wir müssen zuerst eine leere Liste erstellen
l_mat <- list()
# Nun können wir die Matrix an Position drei einfügen
l_mat[[3]] <- an_mat
str(l_mat) # Check
# Die Matrix zu t2 ist definiert durch mat(t3) - 1
an_mat_2 <- an_mat - 1
length(an_mat - an_mat_2) == nrow(an_mat) * ncol(an_mat) # Resultat muss TRUE sein
head(an_mat_2) # Check
# Füge die Matrix der Liste hinzu
l_mat[[2]] <- an_mat_2
str(l_mat) # Check
# Matrix mit lauter 0 erstellen
an_mat_0 <- matrix(0, nrow(an_mat), ncol(an_mat))
sum(an_mat_0) == 0 # Check. Resultat muss TRUE sein
# Matrix der Liste hinzufügen
l_mat[[1]] <- an_mat_0 # Matrix hinzufügen
str(l_mat) # Check
Am häufigsten werden Daten in sogenannten Data Frames gespeichert. Sofern das systematisch passiert, macht dies die Arbeit mit Daten einfach. Technisch gesprochen, handelt es sich bei Data Frames um aneinander gebundene gleich langen Vektoren. Jeder dieser Vektoren kann eine andere Objektklasse haben. Anders als bei Matrizen können Data Frames daher Elemente unterschiedlicher Objektklassen enthalten. Deshalb handelt es sich bei Data Frames um eine zweidimensionale Struktur, die sowohl Eigenschaften einer Matrix als auch einer Liste hat.
Data Frames können auf unterschiedliche Art hergestellt werden. Am einfachsten funktioniert es mit dem Befehl data.frame() und benannten Vektoren als Input.
# Data Frame erstellen
df <- data.frame(x = 1:4,
y = c("a", "b", "c", "d"),
z = c(TRUE, FALSE, FALSE, TRUE))
# Einige Infos abfragen
df
class(df)
is.data.frame(df)
nrow(df)
ncol(df)
length(df)
str(df)
Vorsicht bei der Arbeit mit Text (Strings)! Data Frames wandeln Text standardmässig in Faktoren um. Das ist aber nicht das, was wir bei Textanalysen möchten. Deshalb müssen wir in solchen Fällen explizit sagen, dass stringsAsFactors = FALSE ist.
df <- data.frame(x = 1:4,
y = c("a", "b", "c", "d"),
z = c(TRUE, FALSE, FALSE, TRUE),
stringsAsFactors = FALSE)
str(df)
Ansteuern lassen sich Daten in Data Frames auf unterschiedliche Weise.
df <- data.frame(x = 1:4,
y = c("a", "b", "c", "d"),
z = c(TRUE, FALSE, FALSE, TRUE))
# mittels Index
df[, 2]
df[, 2:3]
# mittels Name
df[, "y"]
df$y
Data Frames lassen sich mit Hilfe von rbind() und cbind() auch kombinieren. Wichtig dabei ist, dass die Anzahl Zeilen resp. Spalten übereinstimmen.
df_1 <- data.frame(person = c("Maria", "Daniel", "Anna", "Peter", "Ursula",
"Thomas", "Sandra", "Christian", "Ruth", "Martin"),
kuchen = sample(c(0, 1), 10, replace = TRUE, prob = c(0.8, 0.2)))
df_1
df_2 <- data.frame(getränk = sample(c("Espresso", "Kafi", "Schale",
"Cappuccino"), 10, replace = TRUE),
zucker = sample(c(TRUE, FALSE), 10, replace = TRUE))
df_2
# Data Frames zeilenweise verbinden (nebeneinander)
df <- cbind(df_1, df_2)
df
# Data Frame spaltenweise verbinden (untereinander)
df <- rbind(df_1, df_2)
# Hoppla... das funktioniert so nicht. Wo liegt das Problem?
# Einzelne Spalten lassen sich auch direkt hinzufügen
df_1$zucker <- sample(c(TRUE, FALSE), 10, replace = TRUE)
df_1
Weitere Funktionen für die Arbeit mit Data Frames sind head(), tail(), names(), summary() und order().
# Exkurs: damit Zufallsziehungen replizierbar sind, muss ein Seed gesetzt werden.
# Wird auf einen Seed verzichtet, arbeitet der Zufallsgenerator von R jedesmal anders (zufällig eben).
set.seed(123)
# Generieren von df_kafi
df_kafi <- data.frame(person = as.character(c("Maria", "Daniel", "Anna", "Peter", "Ursula",
"Thomas", "Sandra", "Christian", "Ruth", "Martin")),
kuchen = sample(c(0, 1), 10, replace = TRUE, prob = c(0.8, 0.2)),
getränk = sample(c("Espresso", "Kafi", "Schale",
"Cappuccino"), 10, replace = TRUE),
zucker = sample(c(TRUE, FALSE), 10, replace = TRUE))
df_kafi
# die ersten Zeilen anschauen
head(df_kafi)
# die letzten Zeilen anschauen
tail(df_kafi)
# die Spaltennamen anschauen
names(df_kafi)
# die Spaltennamen ändern
names(df_kafi) <- c("Person", "Kuchen", "Getränk", "Zucker")
# ein bisschen beschreibende Statistik
summary(df_kafi)
# df_kafi nach kuchen sortieren (aufsteigend)
df_kafi[order(df_kafi$Kuchen), ]
# df_kafi nach kuchen sortieren (absteigend)
df_kafi[order(-df_kafi$Kuchen), ]
Ein zweites Team möchte ebenfalls zum Kafi kommen, den du organisierst. Sie haben intern bereits eine Umfrage gemacht und liefern dir die Daten. Um eine bessere Übersicht zu haben, möchtest du die beiden Datensätze zusammenspielen und ein paar Infos daraus ziehen.
df_t2 und df_kafi (siehe oben) zusammen. Achte darauf, dass die Information, wer zu welchem Team gehört, erhalten bleibt.df_t2 <- data.frame(name = c("Elisabeth", "Andreas", "Anna", "Peter", "Marco", "Verena"),
kuchen = sample(c(0, 1), 6, replace = TRUE, prob = c(0.8, 0.2)),
heissgetränk = sample(c("Espresso", "Kafi", "Schale",
"Tee"), 6, replace = TRUE),
zucker = sample(c(TRUE, FALSE), 6, replace = TRUE),
stringsAsFactors = FALSE)
df_kafi <- data.frame(person = c("Maria", "Daniel", "Anna", "Peter", "Ursula",
"Thomas", "Sandra", "Christian", "Ruth", "Martin"),
kuchen = sample(c(0, 1), 10, replace = TRUE, prob = c(0.8, 0.2)),
getränk = sample(c("Espresso", "Kafi", "Schale",
"Cappuccino"), 10, replace = TRUE),
zucker = sample(c(TRUE, FALSE), 10, replace = TRUE))
df_t2 <- data.frame(name = c("Elisabeth", "Andreas", "Anna", "Peter", "Marco", "Verena"),
kuchen = sample(c(0, 1), 6, replace = TRUE, prob = c(0.8, 0.2)),
heissgetränk = sample(c("Espresso", "Kafi", "Schale",
"Tee"), 6, replace = TRUE),
zucker = sample(c(TRUE, FALSE), 6, replace = TRUE),
stringsAsFactors = FALSE)
# 1. Datensätze zusammenspielen
# Namen von df_t2 anpassen
names(df_t2) <- c("person", "kuchen", "getränk", "zucker")
# neue Variable für Teamzugehörigkeit erstellen
df_kafi$team <- 1
df_t2$team <- 2
# Datensätze zusammenspielen
df_comp <- rbind(df_kafi, df_t2)
# 2. Statistik
summary(df_comp)
# 3. Kuchen
df_dank <- df_comp[df_comp$kuchen == 1, ]
df_dank
Im Forschungsalltag kommt es häufig vor, dass unsere Datensätze nicht vollständig sind: In Umfragen haben einige Personen nur unvollständige Angaben gemacht, in digitalen Archiven fehlen Einträge für bestimmte Zeitpunkte, zum Beispiel weil unsere Web-Scraping-Skripte nicht alle Informationen erfasst haben. Kurzum, fehlende Daten haben viele mögliche Ursachen und sind allgegenwärtig. Wichtig ist für uns zu lernen, wie wir mit fehlenden Daten umgehen.
In R werden fehlenden Werte mit NA (“not available”) gekennzeichnet. NA ist logical, passt sich aber seiner Umgebung an.
# NA ist logical
class(NA)
# Passt sich aber seiner Umgebung an, hier numerical
age <- c(22, 27, NA, NA, 57, 71, 83)
class(age)
length(age)
age[3]
class(age[3])
Fehlende Werte können bei Auswertungen problematisch sein, entweder weil sie zu Fehlermeldungen führen oder die Resultate verzerren. Daher sollten wir vor der Analyse immer überprüfen, ob wir in unseren Daten fehlende Werte haben. Dafür gibt es die Funktion is.na(). Das Ergebnis von is.na() ist ein logischer Vektor. Die Funktion durchläuft das jeweilige Datenobjekt, hier einen Vektor (age), und gibt für jeden Eintrag aus, ob es ein fehlender Wert (TRUE) oder ein vorhandender Wert (FALSE) ist. Das ist zwar nützlich, kann aber bei grösseren Datensätzen schnell unübersichtlich werden. Hier wollen wir in erster Linie wissen, wie viele Werte insgesamt fehlen bzw. vorhanden sind.
# Auf NA überprüfen
is.na(age)
# Summe der fehlenden Werte
sum(is.na(age))
# Mit Hilfe der Negierung ! können wir auch nicht-fehlende Werte identifizieren
!is.na(age)
sum(!is.na(age))
Ähnlich funktioniert das auch in Data Frames. Hier können wir die Funktionen colSums() bzw. rowSums() verwenden, um die Summe der fehlenden Werte in Spalten und Zeilen festzustellen. Noch genauer lassen sich NA mit Hilfe der Funktion which() lokalisieren. Das Resultat von wich() ist ein fortlaufender Index. Solche Indizes sind vor allem für Umcodierungen praktisch. Die kompletten Fälle (d.h. Zeilen) lassen sich mit der Funktion complete.cases() identifizieren.
# Data Frame erstellen
ppl <- c("Emma", "Luca", "Mirko", "Nina", "Bernhard", "Erna", "Albrecht")
plz <- c(3012, 3014, 99, 99, 4051, 4536, 3604)
veg <- c("Ja", "Ja", NA, "Nein", "Nein", "Nein", "Nein")
clr <- c("rot", "blau", "grün", "grün", "rot", "blau", "blau")
fan <- c("YB", "FC Breitenrain", "FC Luzern", "Xamax", "YB", "YB", "FC Thun")
hgt <- c(181, 192, 173, 174, 162, 181, 163)
srv <- data.frame(ppl, plz, veg, clr, fan, hgt, age)
str(srv)
srv$ppl <- as.character(srv$ppl)
srv$fan <- as.character(srv$fan)
# Wie viele fehlende Werte gibt es in srv insgesamt?
sum(is.na(srv))
# Wie viele sind es pro Spalte (Variable)?
colSums(is.na(srv))
# Wo befinden sich die fehlenden Werte?
which(is.na(srv))
# Welche Fälle sind vollständig?
complete.cases(srv)
In vielen Datensätzen, gerade wenn sie manuell codiert wurden, werden für fehlende Daten häufig die Werte “99” oder “999” verwendet. Wenn wir externe Datensätze verwenden, müssen wir also zunächst überprüfen, welcher Bezeichnung allfällig fehlende Daten haben und sie danach in die R konforme Bezeichnung NA übersetzen.
Im Fall unseres Data Frames srv sehen wir, dass zwei Postleitzahlen als 99 codiert wurden. Das kann nicht stimmen. Wir ersetzten die Werte in der entsprechenden Spalte deshalb durch NA.
# Postleitzahlen 99 zu NA umcodieren
srv$plz[srv$plz == 99] <- NA
# Umcodierung überprüfen
srv$plz
colSums(is.na(srv))
Sind alle fehlenden Werte richtig codiert, stellt sich die Frage, wie man bei der Analyse mit ihnen umgeht. Eine Möglichkeit ist der Ausschluss von unvollständigen Fällen mit Hilfe der Funktion na.omit(). Da dies aber immer auch mit einem Verlust von vorhandene Daten einhergeht, ist der Ausschluss einzelner Werte direkt bei der Auswertung meist die elegantere Lösung. In dem Fall bleiben die NA in den Daten und werden erst durch eine entsprechende Option in der Analysefunktion ausgeschlossen (an den Daten ändert sich dadurch nichts). Die meisten Funktionen kennen eine solche Option. Sie heisst oft na.rm (aber nicht immer!). Damit können wir angeben, ob fehlende Werte ausgeschlossen werden sollen: na.rm = TRUE. Ob eine Funktion eine entsprechende Option hat und wie sie genau aktiviert wird, lässt sich mit der Hilfe-Funktion nachschlagen: z.B. ?mean.
# Ausschluss unvollständiger Fälle mit na.omit()
srv_cpl <- na.omit(srv)
# Mittelwert mit fehlenden Daten
mean(srv$age) # Funktioniert nicht, da wir fehlende Altersangaben haben
mean(srv$age, na.rm = TRUE) # Auschluss fehlender Werte mittels na.rm = TRUE
srv. Stelle sicher, dass die Datentypen korrekt bleiben.abo hinzu: abo <- c(1, 1, 0, 0, 99, 0, 99, 1). Sie informiert als Dummy-Variable (1, 0) darüber, ob eine Person ein Saisonabo des favorisierten Fussballclubs hat oder nicht. Fehlende Werte sind dabei mit 99 angegeben. Um damit arbeiten zu können, musst du die fehlenden Werte umcodieren (99 zu NA). Wie viele Personen haben ein Saisonabo ihres Clubs?# 1. Integration von Klaus
kls <- c("Klaus", 8280, NA, "grün", "FC St. Gallen", NA, 37)
srv <- rbind(srv, kls)
str(srv)
srv$plz <- as.numeric(srv$plz)
srv$hgt <- as.numeric(srv$hgt)
srv$age <- as.numeric(srv$age)
# 2. Fehlende und vorhandene Werte
sum(is.na(srv))
sum(!is.na(srv))
# 3. Fehlende Werte pro Person
rowSums(is.na(srv))
# 4. Median-Grösse
median(srv$hgt, na.rm = TRUE)
# 5. Variable abo hinzufügen
abo <- c(1, 1, 0, 0, 99, 0, 99, 1)
# 99 zu NA umcodieren
abo[abo == 99] <- NA
# Variable hinzufügen
srv$abo <- abo
# Saisonabos zählen
sum(srv$abo, na.rm = TRUE)
R ist eine funktionale Programmiersprache. Funktionen sind Objekte, die benannt, gespeichert, wiederverwendet, manipuliert und auf vielfältige Weise miteinander kombiniert werden können (z.B. ineinander verschachtelt oder nebeneinander in einer Liste). Also alles, was wir auch mit Vektoren machen können. Im Unterschied zu Vektoren, können Funktionen aber andere Objekte auf bestimmte, klar definierte Art und Weise verändern. Zudem müssen sie nicht unbedingt einen Namen haben, sondern können sich auch hinter Operatoren verbergen, wie zum Beispiel +, %in% oder }. Bei fast allem, was wir in R machen, brauchen wir Funktionen.
Base-R kennt bereits viele Funktionen: head(), class(), length() sowie alle anderen Funktionen, mit denen wir bereits gearbeitet haben. Reichen uns diese nicht aus, können wir beliebige weitere Funktionen definieren. Diese bestehen immer aus drei Elementen:
body, der den Code enthält,formals, die von der Funktion benötigt werden,environment, in dem die Variablen der Funktion gespeichert werden.Da uns environment fürs Erste nicht zu interessieren braucht, folgt die Schreibweise bei eigenen Funktionen immer dem Muster meine_funktion <- funktion(formals) {body}. Wenn innerhalb der Funktion neue Objekte erzeugt werden, muss der gewünschte Output mittels return() spezifiziert werden. Ansonsten wird nur das zuletzt berechnete Objekt ausgegeben.
# Funktion erstellen
erste_funktion <- function(x, y) {
x + y
}
# Bestandteile der Funktion anschauen
body(erste_funktion)
formals(erste_funktion)
environment(erste_funktion)
# Gleiche Funktion mit return
erste_funktion_alt <- function(x, y) {
z <- x + y
return(z)
}
# Funktion anwenden
a <- 10
b <- 5
erste_funktion(a, b)
erste_funktion_alt(a, b)
hallo, in die du den Namen einer beliebigen Person hineingeben kannst, die dann mit "Hallo Name!" gegrüsst wird (also z.B. "Hallo Daniel!"). Tipp: Verwende im Body die Funktion paste0().pythagoras, mit der bei bekannten Katheten \(a\) und \(b\) sowie der Hypothenuse \(c\) überprüft werden kann, ob ein Dreieck rechtwinkling ist. Der Output der Funktion soll TRUE sein für ein rechtwinkliges Dreieck und FALSE in jedem anderen Fall.
recode_missings, um alle 99 im folgenden Data Frame durch NA zu ersetzen.df <- data.frame(replicate(6, sample(c(1:10, 99), 6, rep = TRUE)))
names(df) <- letters[1:6]
# 1. Hallo
# Funktion definieren
hallo <- function(name) {
paste0("Hallo ", name, "!")
}
# Funktion ausprobieren
hallo("Daniel")
# 2. Satz von Pythagoras
# Funktion definieren
pythagoras <- function(a, b, c) {
a ^ 2 + b ^ 2 == c ^ 2
}
# Dreiecke überprüfen
pythagoras(3, 4, 5)
pythagoras(4, 5, 6)
# 3. 99 durch NA ersetzten
# Funktion definieren
recode_missings <- function(df) {
df[df == 99] <- NA
return(df)
}
# Missings umkodieren
recode_missings(df)
Um Abläufe besser steuern zu können, kennt R verschiedene Kontrollstrukturen. Das sind spezielle Funktionen, die besonders hilfreich sind, wenn sich Arbeitsschritte mehrfach wiederholen. Ihre Verwendung ist insbesondere in Funktionen sinnvoll.
if-else-Anweisung
if (Bedingung) {Anweisung_1} else {Anweisung_2}Bedinung gleich TRUE, dann wird Anweisung_1 ausgeführt, ansonsten Anweisung_2x <- ifelse(Bedingung, Anweisung_1, Anweisung_2)for-Schleife
for (i in Sequenz) {Anweisung}i (Zählvariable) in der Sequenz wird Anweisung ausgeführt.while-Schleife
while (Bedingung) {Anweisung}Bedingung gleich TRUE ist, wird Anweisung wiederholt. Wenn Anweisung gleich FALSE wird, wird die Ausführung von Anweisung angehalten.# Beispiel if-else-Anweisung
x <- sample(1:20, 1)
if (x <= 10) {
paste(x, "ist kleiner als 10")
} else {
paste(x, "ist grösser als 10")
}
# Beispiel ifelse-Anweisung
ifelse(x <= 10, paste(x, "ist kleiner als 10"), paste(x, "ist grösser als 10"))
# Beispiel for-Schleife
for (x in 1:10) {
print(letters[x])
}
# Beispiel while-Schleife
y <- 1
while (y < 10) {
print(y)
y <- y + 1
}
# Kontrollstrukturen können auch verschachtelt...
voc <- c("A", "E", "I", "O", "U")
for (i in 1:length(LETTERS)) {
if (LETTERS[i] %in% voc) {
print(paste(LETTERS[i], "ist ein Vokal."))
} else {
print(paste(LETTERS[i], "ist ein Konsonant."))
}
}
# und in Funktionen eingebaut werden.
check_vocals <- function(let) {
voc <- c("A", "E", "I", "O", "U")
for (i in 1:length(let)) {
if (let[i] %in% voc) {
print(paste(let[i], "ist ein Vokal."))
} else {
print(paste(let[i], "ist ein Konsonant."))
}
}
}
check_vocals(LETTERS[10:20])
Anmerkung: Schleifen sind zwar praktisch und sehr gut nachzuvollziehen, aber nicht unbedingt sehr effizient. In den meisten Fällen können sie durch die kompakteren Funktionen apply()/sapply()/lapply() (base-R) oder map() (aus dem purrr-Package) ersetzt werden. Das folgende Beispiel zeigt, wie eine for-Schleife durch apply() vereinfacht werden kann.
m <- matrix(c(seq(from = -98, to = 100, by = 2)), nrow = 10, ncol = 10)
m
# Berechnen der Zeilen- und Spaltensummen mit einer for-Schleife
# Zeilensumme
for (i in 1:nrow(m)) {
s <- sum(m[i, ])
print(s)
}
# Spaltensumme
for (i in 1:ncol(m)) {
s <- sum(m[, i])
print(s)
}
# Berechnen der Zeilen- und Spaltensumme mit apply
# Zeilensummen
apply(m, 1, sum)
# Spaltensummen
apply(m, 2, sum)
x <- sample(1:20, 1) und y <- sample(1:20, 1). Schreibe dann eine if-else-Anweisung, um den kleineren der beiden Werte vom grössen abzuziehen (Subtraktion). Sollten x und y zufälligerweise gleich gross sein, spielt die Reihenfolge keine Rolle (das Resultat ist in beiden Fällen 0).z <- sample(1:20, 25, rep = TRUE). Schreibe einen for-Loop, zum für jedes Element e in z die zweite Potenz e ^ 2 auszurechnen. Speichere die Resultate der Reihe nach in einem neuen Vektor z_quad.zucker etwas durcheinander geraten. Anstatt TRUE und FALSE enthält sie jetzt auch noch ja, nein, 1 und 0. Schreibe eine Funktion clean_sugar, die das wieder in Ordnung bringt und alle ja, nein, 1 und 0 in TRUE und FALSE umkodiert. Stell sicher, dass die Spalte am Schluss wieder die Klasse logical/boolean hat. Tipp: Benutze dafür eine for-Schleife.# Datensatz neu erstellen
df_kafi <- data.frame(person = c("Maria", "Daniel", "Anna", "Peter", "Ursula",
"Thomas", "Sandra", "Christian", "Ruth", "Martin",
"Elisabeth", "Andreas", "Anna", "Peter", "Marco", "Verena"),
kuchen = sample(c(0, 1), 16, replace = TRUE, prob = c(0.8, 0.2)),
getränk = as.factor(sample(c("Espresso", "Kafi", "Schale",
"Cappuccino", "Tee"), 16, replace = TRUE)),
zucker = sample(c(TRUE, FALSE, "ja", "nein", 1, 0), 16, replace = TRUE),
team = as.factor(c(rep(1, 10), rep(2, 6))),
stringsAsFactors = FALSE)
# 1. Subtraktion
x <- sample(1:20, 1)
y <- sample(1:20, 1)
if (x >= y) {
x - y
} else {
y - x
}
# 2. Potenzieren
z <- sample(1:20, 25, rep = TRUE)
z_quad <- as.numeric()
for (e in 1:length(z)) {
z_quad[e] <- z[e] ^ 2
}
# 3. clean_sugar
df_kafi <- data.frame(person = c("Maria", "Daniel", "Anna", "Peter", "Ursula",
"Thomas", "Sandra", "Christian", "Ruth", "Martin",
"Elisabeth", "Andreas", "Anna", "Peter", "Marco", "Verena"),
kuchen = sample(c(0, 1), 16, replace = TRUE, prob = c(0.8, 0.2)),
getränk = as.factor(sample(c("Espresso", "Kafi", "Schale",
"Cappuccino", "Tee"), 16, replace = TRUE)),
zucker = sample(c(TRUE, FALSE, "ja", "nein", 1, 0), 16, replace = TRUE),
team = as.factor(c(rep(1, 10), rep(2, 6))),
stringsAsFactors = FALSE)
##############
# Variante 1 #
##############
# Funktion definieren
clean_suger <- function(col, old, new) {
for (i in 1:length(old)) {
col[col == old[i]] <- new[i]
}
col <- as.logical(col)
return(col)
}
# Funktion anwenden
df_kafi$zucker <- clean_suger(df_kafi$zucker,
c("1", "0", "ja", "nein"),
c("TRUE", "FALSE", "TRUE", "FALSE"))
##############
# Variante 2 #
##############
# Funktion definieren
clean_sugar <- function(def) {
pos <- c("ja", 1)
for (i in 1:length(def)) {
if (def[i] %in% pos) {
def[i] <- "TRUE"
} else {
def[i]<- "FALSE"
}
}
def <- as.logical(def)
return(def)
}
# Funktion
df_kafi$zucker <- clean_sugar(df_kafi$zucker)
Funktionen können nicht nur selbst geschrieben, sondern mit Hilfe von Packages auch einfach importiert werden. Packages sind Sammlungen von Funktionen (und manchmal auch Datensätzen), die Leute aus der R-Community programmiert und veröffentlicht haben. Auf CRAN, dem Comprehensive R Archive Network, sind aktuell über 13’600 Packages verfügbar (siehe Liste).
Die Installation von Packages erfolgt mittels Befehl install.packages("Package-Name"). Um die in einem Package enthaltenen Funktionen nutzen zu können, muss es mit library(Package-Name) geladen werden.
# Installieren des Packages dplyr
install.packages("dplyr")
# Liste der installierten Packages anzeigen
library()
# Package dplyr laden
library(dplyr)
# Liste der geladenen Packages anzeigen
search()
# Neue Funktionen verwenden
filter(df_kafi, getränk == "Tee")
# Package ausladen
detach("package:dplyr")
Mit einem Package kommt immer auch die Dokumentation der enthaltenen Funktionen. Auf die Dokumentation kann via ?Funktion zugegriffen werden.
Zudem existiert auf CRAN für jedes Package eine Webpage mit Informationen zum Package selbst (Versionsgeschichte, Abhängigkeiten, Autorenschaft, etc.) und einem Manual, in dem alle Funktionen beschrieben sind. Manchmal finden sich auf der CRAN-Page auch äusserst hilfreiche Links zu Vignetten, Tutorials und weiteren Publikationen.
Hier geht es zur Page von dplyr.
Meist führen in R viele Wege zum Ziel. Es lohnt sich aber, sich beim Coden an ein paar Regeln zu halten und schönen Code zu schreiben, denn “Good coding style is like using correct punctuation. You can manage without it, but it sure makes things easier to read” (Hadley Wickham). Das ist vor allem wichtig, wenn mehrere Personen zusammen an einem Projekt arbeiten. Es hilft aber auch bei eigenen Projekten, wenn man beispielsweise länger nicht mehr mit dem Code gearbeitet hat und sich wieder “Eindenken” muss oder verzweifelt versucht einen Fehler zu beheben.
# werden
df_kafi, nicht dfkafi, DfKafi oder df_kafi-klatsch_team1+2=, +, ==, &&, %>%, etc.), z.B. x == y= in Funktionen, z.B. mean(x = c(1, 3, 5))df_kafi[, 3]if (getränk == "Tee"), sum(1:10)$ und @ für Subsets, z.B. df_kafi$getränkdata.frame(...) und if-Anweisungen{ immer ein Umbruch, } immer auf einer eigenen Zeile+ oder %>% verknüpfte tidyverse-FunktionenNumVektor = seq(1,10,by=2)y <- 0
x <- 2
if(y==0)
{log(x)} else {
y^x}
# 1. Numerischer Vektor
num_vec <- seq(1, 10, by = 2)
# 2. Korrigierte if-else-Anweisung
y <- 0
x <- 2
if (y == 0) {
log(x)
} else {
y ^ x
}
Die Daten in die richtige Form zu bringen, braucht oft mehr Zeit als die eigentliche Analyse. Dem Data Wrangling soll in dieser Einführung deshalb besonderes Augenmerk gelten. Dabei nutzen wir vor allem die Packages aus dem tidyverse, einer Sammlung von Packages, die speziell für die Data-Science-Zwecke designt wurden. Alle Packages im tidyverse funktionieren nach der gleichen Philosophie, bedingen die gleiche Grammatik und verwenden die gleichen Datenstrukturen.
install.packages("tidyverse")
library(tidyverse)
R kann Daten in fast jedem Format importieren und exportiert werden. Zu den besonders häufig verwendeten Formaten gehören jedoch Text- und Exceldateien sowie das Dateien im .RData-Format.
Die passenden Funktionen für Text- und Exceldateien finden sich in den Packages readr und readxl aus dem tidyverse.
read_csv(): Komma-getrennt (CSV)read_csv2(): Semikolon-getrenntread_tsv(): Tabulator-getrenntread_delim(): alle anderen Trennzeichenread_fwf(): feste Breitereadxl)
read_excel(): für .xls und .xlsxFür den Daten-Import gibt es ein sehr hilfreiches Cheat Sheet.
# Komma-getrenntes CSV laden
exp_csv <- read_csv("data/mtcars.csv")
# Daten anschauen
glimpse(exp_csv)
# und als |-getrenntes TXT speichern
write_delim(exp_csv, path = "data/mtcars_delim.txt", delim = "|")
# |-getrenntes TXT einlesen
exp_delim <- read_delim("data/mtcars_delim.txt", delim = "|")
# Für den Import von XLS(X)-Dateien muss readxl extra geladen werden
# Es ist kein Core-Package des tidyverse
library(readxl)
# XLSX: Blätter in Datei anschauen
excel_sheets("data/datasets.xlsx")
# XLSX: gewünschtes Blatt importieren
exp_xlsx <- read_excel("data/datasets.xlsx", sheet = "mtcars")
Zum Importieren von SPSS und STATA-Dateien können Funktionen aus dem haven-Package (ebenfalls im tidyverse) verwendet werden.
read_sav() (oder read_por() für POR-Dateien)read_dta()Werden Daten nur für die Weiterverarbeitung in R gespeichert, kann das .RData-Format verwendet werden. Da es sich um ein binäres Dateiformat handelt, ist es sehr platzsparend. Der Nachteil von .RData ist, das eine kaputte Datei nicht repariert werden kann.
# Daten als RData speichern
save(exp_csv, file = "data/mtcars.RData")
# Es können auch mehrere Objekte gespeichert werden
save(exp_csv, exp_xlsx, file = "data/mtcars.RData")
# RData-File laden
load("data/mtcars.RData")
col_types und col_factor (siehe hier), dass die Spalten “orga_pos”, “orga_type” und “orga_ctr” als Faktoren gelesen werden. Die Faktoren haben folgende Levels:network_comp.RData.# 1. Daten importieren
# edges.csv und nodes.csv einlesen
edges <- read_csv("data/edges.csv")
nodes <- read_csv("data/nodes.csv",
col_types = cols(orga_pos = col_factor(levels = c(1, 2, 99)),
orga_type = col_factor(levels = c(1, 2, 3, 4, 5, 99)),
orga_ctr = col_factor(levels = c(1, 3, 4, 17, 75, 180, 888, 999))))
# pages.RData einlesen
load("data/pages.RData")
# 2. Daten speichern
save(edges, nodes, pages, file = "data/network_comp.RData")
Daten können auf sehr unterschiedliche Weise organisiert werden (und kommen selten in der Weise, die man möchte… dazu unten gleich noch ausführlicher). Schau dir zum Beispiel die Objekte table1, table2, table3, table4a und table4b an. Jeder dieser Datensätze enthält Informationen zum Land, dem Jahr, der Population und der Anzahl Fälle. Die Organisation der Tables ist aber in jedem Fall anders. Solange wir uns im tidyverse bewegen, ist es am einfachsten (Design der Funktionen) und schnellsten (Vektorisierung), wenn man mit tidy Daten arbeitet (Wickham, 2014).
Tidy Datensätze sind nach drei Regeln organisiert:
Frage: Welcher der Tables table1, table2, table3, table4a und table4b ist tidy?
Nur table1 ist tidy.
Um Datensätze zu transformieren (z.B. um sie tidy zu machen), gibt es in den tidyverse-Packages dplyr und tidyr zahlreiche nützliche Funktionen. Zu den wichtigsten gehören:
filter(): Fälle auswählen (Zeilen)arrange(): Fälle sortierenselect(): Spalten auswählen (Variablen)mutate(): Neue Variablen aus bestehenden Variablen kreierenunite() und separate(): Variablen zusammenfassen/auftrennensummarise(): Fälle aggregierengroup_by(): Anwendung von Funktionen auf Gruppen anstatt auf ganzen Datensatzgather(): Verschiebt Spaltennamen in eine Key-Spalte und fasst die Werte der Spalten in einer einzigen Value-Spalte zusammen (wide to long)spread(): Verschiebt die Werte einer Key-Spalte in die Spaltennamen und verteilt die Werte einer Value-Spalte über die neuen Spalten (long to wide)join(): Kombiniert Datensätze aufgrund übereinstimmender Werte in SpaltenEin hilfreiches Cheat Sheet für die Arbeit mit dplyr ist das Data Transformation Cheat Sheet. Bei den tidyr-Funktionen hilft das Data Import Cheat Sheet weiter.
Mit filter() lassen sich Fälle aufgrund ihrer Werte auswählen.
# Ergänzter Datensatz df_kafi für Beispiele erzeugen
df_kafi <- data.frame(person = c("Maria", "Daniel", "Anna", "Peter", "Ursula",
"Thomas", "Sandra", "Christian", "Ruth", "Martin",
"Elisabeth", "Andreas", "Anna", "Peter", "Marco",
"Verena", "Marco", "Nicole", "Barbara", "Stefan",
"Erika", "Marcel", "Stefanie", "Karin", "Andrea"),
team = as.factor(c(rep(1, 10), rep(2, 6), rep(3, 9))),
getränk = as.factor(sample(c("Espresso", "Kafi", "Schale",
"Cappuccino", "Tee"), 25, replace = TRUE)),
zusatz = sample(c("Zucker, Kafirahm", "Zucker, Milch",
"Milch, Zucker", "Zucker", "Milch", NA),
25, replace = TRUE, prob = c(rep(0.15, 5), 0.25)),
allergie = as.factor(sample(c(NA, "Zöliakie", "Laktose"), 25,
replace = TRUE, prob = c(0.95, 0.02, 0.03))),
kuchen = sample(c(0, 1), 25, replace = TRUE, prob = c(0.8, 0.2)),
geschenk = sample(5:20, 25, replace = TRUE),
stringsAsFactors = FALSE)
# ein Kriterium
filter(df_kafi, getränk == "Espresso")
filter(df_kafi, geschenk >= 10)
filter(df_kafi, team != 1)
filter(df_kafi, !is.na(allergie))
# mehrere Auswahlkriterien
filter(df_kafi, getränk == "Espresso", geschenk >= 10) # UND
filter(df_kafi, getränk == "Espresso" & geschenk >= 10) # UND
filter(df_kafi, getränk == "Espresso" | getränk == "Kafi") # ODER
filter(df_kafi, getränk %in% c("Schale", "Cappuccino")) # ODER
filter(df_kafi, !(geschenk < 8 | geschenk > 12) & kuchen == 1) # verschachtelt
Beachte, dass bei keiner Manipulation mit tidyverse-Funktionen das ursprüngliche Objekt verändert wird. Soll mit dem veränderten Objekt weitergearbeitet werden, muss daher mit <- ein neues Objekt erzeugt werden (oder das alte überschrieben werden).
# a) Laktoseintoleranz
filter(df_kafi, allegier == "Laktose")
# b) Schale mit Zusatz
filter(df_kafi, getränk == "Schale" & !is.na(zusatz))
# c) Weniger als CHF 10 und keinen Kuchen
filter(df_kafi, geschenk < 10 & kuchen != 1)
# d) Nicht in Team 1 und keine Allergie
filter(df_kafi, team != 1 & !is.na(allergie))
Mit arrange() lassen sich Fälle sortieren.
# aufsteigend
arrange(df_kafi, geschenk)
# absteigend
arrange(df_kafi, desc(geschenk))
# nach mehreren Kriterien
arrange(df_kafi, team, desc(geschenk))
Mit select() lassen sich Variablen auswählen.
# positiv
select(df_kafi, person, team, getränk, zusatz, allergie)
# negativ
select(df_kafi, -kuchen, -geschenk)
# mit Hilfefunktionen
select(df_kafi, starts_with("ge"))
select(df_kafi, ends_with("k"))
select(df_kafi, contains("er"))
# Kombination
select(df_kafi, person, team, ends_with("k"))
Noch mehr Hilfefunktionen finden sich in der Dokumentation via ?select_helpers.
Mit select() können auch Variablen umbenannt werden. Einfacher ist aber, wenn dafür rename(), eine Variante von select() verwendet wird.
rename(df_kafi, vorname = person, beitrag = geschenk)
Mit mutate() lassen sich mit Hilfe von Funktionen neue Variablen kreiern, basierend auf bestehenden.
# Reduktion der Geschenkbeiträge um CHF 3
mutate(df_kafi, geschenk_redu = geschenk - 3)
# Differenz der Geschenkbeiträge zum Durchschnitt
mutate(df_kafi, geschenk_diff = geschenk - mean(geschenk))
# Keine Geschenkbeiträge für Leute, die Kuchen bringen
mutate(df_kafi, geschenk_ok = ifelse(kuchen == 1, 0, geschenk))
# Getränke umkodieren
mutate(df_kafi, getränk_num = case_when(getränk == "Espresso" ~ 1,
getränk == "Kafi" ~ 2,
getränk == "Schale" ~ 3,
getränk == "Cappuccino" ~ 4,
getränk == "Tee" ~ 5))
Sollen nur die neuen Variablen behalten werden, kann transmute() anstatt mutate() verwendet werden.
df_kafi die Variable geschenk_kl mit den drei Ausprägungen wenig, mittel und viel, abhängig davon ob jemand weniger als CHF 11, weniger als CHF 16 oder mehr als CHF 16 an das Geschenk zahlt.df_kafi die Dummy-Variable zucker, die anzeigt, ob eine Person ihr Getränk mit oder ohne Zucker trinkt. Tipp: Verwende die Funktion str_detect().# 1. Geschenksbeiträge in Klassen
df_kafi <- mutate(df_kafi, geschenk_kl = case_when(geschenk < 11 ~ "wenig",
geschenk < 16 ~ "mittel",
geschenk >= 16 ~ "viel"))
# 2. Dummy zucker
df_kafi <- mutate(df_kafi, zucker = case_when(str_detect(zusatz, "Zucker") ~ 1,
!str_detect(zusatz, "Zucker") ~ 0,
is.na(zusatz) ~ 0))
(Character-)Variabeln lassen sich auch mit den Funktionen unite() und separate() bearbeiten, nämlich verbinden und auftrennen.
# Variable zusatz auftrennen
separate(df_kafi, zusatz, into = c("zusatz_1", "zusatz_2"), sep = ", ")
# Person und Team verbinden
unite(df_kafi, "pers_t", c(person, team))
Mit summarise() lassen sich Fälle zusammenfassen (aufgrund vorgegebener Regeln).
# Durchschnittlicher Beitrag ans Geschenk
summarise(df_kafi, geschenk_mean = mean(geschenk))
# Anzahl verschiedene Namen
summarise(df_kafi, n_distinct(person))
summarise() ist vor allem mit group_by() ein starkes Team. Mit Hilfe von groupd_by() lassen sich Gruppen definieren, auf die eine Funktion angewendet wird.
# Gruppieren und zusammenfassen mit dplyr
summarise(group_by(df_kafi, team), n_kuchen = sum(kuchen))
Ein grossartiges Feature des tidyverse ist dabei die Pipe %>%. Mit ihr lassen sich mehrere Operationen einfach aneinanderreihen. Auf diese Weise können auch komplexe Transformationen mit relativ wenig Zeilen Code gemacht werden.
# Wie oben, aber mit der Pipe %>%
df_kafi %>%
group_by(team) %>%
summarise(n_kuchen = sum(kuchen))
# Anzahl Personen pro Beitragsklasse
df_kafi %>%
mutate(geschenk_kl = case_when(geschenk < 11 ~ "wenig",
geschenk < 16 ~ "mittel",
geschenk >= 16 ~ "viel")) %>%
group_by(geschenk_kl) %>%
count(geschenk_kl) %>%
arrange(desc(n))
# 1. Wer trinkt den Kafi mit Zucker?
df_kafi %>%
filter(getränk == "Kafi" & zucker == 1) %>%
group_by(team) %>%
count()
# 2. Durcschnittlicher Beitrag ans Geschenk
df_kafi %>%
mutate(allergie_01 = case_when(is.na(allergie) ~ 0,
!is.na(allergie) ~ 1)) %>%
group_by(allergie_01) %>%
summarise(geschenk_mean = mean(geschenk))
Mit gather() und spread() lassen sich das Layout von Datensätzen ändern: von weit zu lang und von lang zu weit. Mit gather() können dabei die Spaltennamen in eine Key-Spalte verschoben und die Werte der Spalten in einer einzigen Value-Spalte zusammengewasst werden (wide to long). Mit spread() ist das Gegenteil möglich: Die Werte einer Key-Spalte werden zu Spaltennamen und die Werte einer Value-Spalte werden über die neuen Spalten verteilt (long to wide).
# Noten aller Studis aus dem Einführungsstudium
noten_wide <- data.frame(student = sample(18000000:18999999, 100, replace = TRUE),
kowi = sample(c(3, 3.5, 4, 4.5, 5, 5.5, 6), 100, replace = TRUE,
prob = c(0.05, 0.2, 0.25, 0.3, 0.2, 0.15, 0.05)),
polito = sample(c(3, 3.5, 4, 4.5, 5, 5.5, 6), 100, replace = TRUE,
prob = c(0.05, 0.2, 0.25, 0.3, 0.2, 0.15, 0.05)),
soz = sample(c(3, 3.5, 4, 4.5, 5, 5.5, 6), 100, replace = TRUE,
prob = c(0.05, 0.2, 0.25, 0.3, 0.2, 0.15, 0.05)))
head(noten_wide)
# wide to long
noten_long <- gather(noten_wide, key = "fach", value = "note", -student)
# long to wide
noten_wide_2 <- spread(noten_long, fach, note)
# Ausblick: Das Long-Format braucht es bei Visualisierungen
ggplot(noten_long, aes(note)) +
facet_wrap(~ fach) +
geom_histogram()
Mit join() lassen sich Variablen aus zwei verschiedenen Datensätzen miteinander kombinieren. Dabei gibt es unterschiedliche Möglichkeiten, wobei im fusionierten Datensatz nicht in jedem Fall die gleichen Daten enthalten sind.
# Noten der Studis aus den Kowi-Vorlesungen
noten_kowi <- data.frame(student = sample(c(noten_wide$student, 16999995:17000010), 45, replace = FALSE),
polkom = sample(c(3, 3.5, 4, 4.5, 5, 5.5, 6), 45, replace = TRUE,
prob = c(0.05, 0.2, 0.25, 0.3, 0.2, 0.15, 0.05)),
medsys = sample(c(3, 3.5, 4, 4.5, 5, 5.5, 6), 45, replace = TRUE,
prob = c(0.05, 0.2, 0.25, 0.3, 0.2, 0.15, 0.05)),
mednutz = sample(c(3, 3.5, 4, 4.5, 5, 5.5, 6), 45, replace = TRUE,
prob = c(0.05, 0.2, 0.25, 0.3, 0.2, 0.15, 0.05)))
head(noten_kowi)
# Info über Datensätze
nrow(noten_kowi)
nrow(noten_wide)
length(unique(noten_kowi$student))
length(unique(noten_wide$student))
# Alle Noten der Studis, die alle sechs Prüfungen geschrieben haben
exp_inner <- inner_join(noten_wide, noten_kowi, by = "student")
head(exp_inner)
nrow(exp_inner)
length(unique(exp_inner$student))
# Alle Noten der Studis aus dem Einführungsstudium plus die Noten aus den Kowi-Vorlesungen, wenn vorhanden
exp_left <- left_join(noten_wide, noten_kowi, by = "student")
head(exp_left)
nrow(exp_left) == nrow(noten_wide)
exp_left$student == noten_wide$student
noten_kowi$student %in% exp_left$student
noten_kowi$student[!(noten_kowi$student %in% exp_left$student)]
# Alle Noten der Studis, die Kowi-Vorlesungen besucht haben
exp_right <- right_join(noten_wide, noten_kowi, by = "student")
head(exp_right)
nrow(exp_right) == nrow(noten_kowi)
filter(exp_right, is.na(kowi))
# Alle Noten aller Studis
exp_full <- full_join(noten_wide, noten_kowi, by = "student")
head(exp_full)
nrow(exp_full)
length(unique(exp_full$student))
rm(list = ls()) dein Global Environment.network_comp.RData, den du oben erstellt hast.nodes kommen die gleichen Organisationen (orga_id, orga_name) mehrfach vor, denn jede Organisation kann mit mehreren Domains (domain) und Dokumenten (doc_id) im Datensatz vertreten sein. Erzeuge auf Basis von nodes einen neuen Datensatz nodes_orga, der
n_domain und n_docs enthält, in denen je die Anzahl verschiedener Domains und Dokumente pro Organisation angegeben wirdorga_-Informationen erhalten bleiben.orga_pos) und Typ (orga_type) gibt.pages folgende Infos zu: Name der Organisation, Position, Typ und Land (orga_ctr)pages, nodes_orga und edges in einem neuen File network_comp_orga.RData.# 1. Datensatz laden
load("data/network_comp.RData")
# 2. Datensatz nodes bereinigen
nodes_orga <- nodes %>%
group_by(orga_id, orga_name, orga_pos, orga_type, orga_ctr) %>%
summarise(n_domains = n_distinct(domain), n_docs = n_distinct(doc_id))
# 3. Akteure pro Position und Typ
nodes_orga %>%
group_by(orga_pos, orga_type) %>%
summarise(sum = n())
# 4. Datensatz pages mit Orga-Info ergänzen
pages <- pages %>%
left_join(nodes_orga, by = "orga_id") %>%
select(-n_domains, -n_docs)
# 5. Bearbeitetes Netzwerk speichern
save(nodes_orga, edges, pages, file = "data/network_comp_orga.RData")
Eine Stärke von R sind Grafiken. Schon mit base-R lassen sich Daten verschieden visualisieren.
# Daten laden
load("data/moviedb.RData")
# Datensatz anschauen
glimpse(moviedb)
# Scatterplot: IMDB-Score und Dauer des Films
plot(imdb_score ~ duration, data = moviedb)
# Es geht auch noch etwas schöner
plot(imdb_score ~ duration, data = moviedb,
ylab = "IMDB score",
xlab = "Dauer des Films (Minuten)")
# Regressionslinie hinzufügen
abline(lm(imdb_score ~ duration, data = moviedb), col = "red")
# Histogram: Anzahl Filme pro Jahr
hist(moviedb$title_year)
hist(moviedb$title_year,
breaks = length(unique(moviedb$title_year)),
main = "Anzahl Filme pro Jahr",
ylab = "Anzahl Filme",
xlab = "Erscheinungsjahr")
# Barplot: Schauspieler*innen mit den meisten Filmen
moviedb_bar <- moviedb %>%
select(actor_1_name, actor_2_name, actor_3_name) %>%
gather(key = "act_nr", value = "actor_name") %>%
filter(!is.na(actor_name)) %>%
group_by(actor_name) %>%
summarise(app = n()) %>%
arrange(desc(app)) %>%
slice(1:20)
barplot(moviedb_bar$app,
names.arg = moviedb_bar$actor_name,
las = 2)
# Oder mit etwas mehr Platz für die Beschriftung der Balken
op <- par(mar = c(10, 4, 2, 2))
barplot(moviedb_bar$app,
names.arg = moviedb_bar$actor_name,
las = 2)
rm(op)
# Boxplot: IMDB-Score für Filme in verschiedenen Sprachen
moviedb_lang <- moviedb %>%
filter(language %in% c("German", "French", "Japanese", "Hindi"))
boxplot(imdb_score ~ language, data = moviedb_lang)
Diese base-Funktionen sind praktisch, um vorhandene Daten schnell zu visualisieren. Für Visualisierungen in Publikationen bietet jedoch das tidyverse-Package ggplot2 sehr viel mehr Möglichkeiten.
Grafiken beginnen in ggplot2 immer mit der Funktion ggplot(). Diese kreiert ein Koordinatensystem, über das beliebige viele Ebenen gelegt werden können. Das erste Argument in ggplot() ist immer der Datensatz, also zum Beispiel ggplot(data = moviedb). Danach werden durch geom-Funktionen weitere Ebenen hinzugefügt. Wichtigstes Argument in geom-Funktionen ist dabei aes(), mit dem unter anderem die X- und Y-Achse definiert werden.
Die einfachste Form eines Grafik mit ggplot sieht immer so aus:
ggplot(data = <DATA>) +
<GEOM_FUNCTION>(mapping = aes(<MAPPINGS>))
Mit der Funktion geom_point() lässt sich beispielsweise ein Scatterplot erstellen:
# Subset von moviedb erzeugen
moviedb_lang <- moviedb %>%
filter(language %in% c("German", "French", "Japanese", "Hindi"))
# Scatterplot: IMDB-Score und Anzahl abgegebene Stimmen
ggplot(data = moviedb_lang) +
geom_point(mapping = aes(x = num_voted_users, y = imdb_score))
# Scatterplot mit Farbe
ggplot(data = moviedb_lang) +
geom_point(mapping = aes(x = num_voted_users, y = imdb_score, color = language))
# Scatterplot in Facets (anstatt Farbe)
ggplot(data = moviedb_lang) +
geom_point(mapping = aes(x = num_voted_users, y = imdb_score)) +
facet_wrap(~ language)
# Scatterplot in Facets und mit Regressionslinien
ggplot(data = moviedb_lang) +
geom_point(mapping = aes(x = num_voted_users, y = imdb_score)) +
facet_wrap(~ language) +
geom_smooth(mapping = aes(x = num_voted_users, y = imdb_score),
method = "lm", se = FALSE)
Barplots lassen sich mit Hilfe von geom_bar() (diskrete Variablen) oder geom_histogram() (stetige Variablen) herstellen.
# Histogramm: Verteilung der IMDB-Scores
ggplot(data = moviedb) +
geom_histogram(mapping = aes(x = imdb_score))
# Histogramm mit angepassten Bins
ggplot(data = moviedb) +
geom_histogram(mapping = aes(x = imdb_score),
binwidth = 0.5)
# Barplot 1: Filme mit "wissenschaftlichen" Keywords
moviedb_bar <- moviedb %>%
select(plot_keywords) %>%
separate(plot_keywords, into = paste0("keyword_", 1:5), sep = "\\|") %>%
gather(key = "var", value = "keyword") %>%
filter(keyword %in% c("university", "science", "scientist",
"research", "researcher", "professor"))
ggplot(data = moviedb_bar) +
geom_bar(mapping = aes(x = keyword))
# Barplot 2: Häufigste Keywords
moviedb_bar <- moviedb %>%
select(plot_keywords) %>%
separate(plot_keywords, into = paste0("keyword_", 1:5), sep = "\\|") %>%
gather(key = "var", value = "keyword") %>%
filter(!is.na(keyword)) %>%
group_by(keyword) %>%
summarise(n_key = n()) %>%
arrange(desc(n_key)) %>%
slice(1:10) %>%
mutate(order = as.numeric(rownames(.)))
# Ohne Beschriftung der Säulen
ggplot(data = moviedb_bar) +
geom_bar(mapping = aes(x = reorder(keyword, order),
y = n_key),
stat = "identity")
# Mit Beschriftung der Säulen (geom_text)
ggplot(data = moviedb_bar) +
geom_bar(mapping = aes(x = reorder(keyword, order),
y = n_key),
stat = "identity") +
geom_text(mapping = aes(x = reorder(keyword, order),
y = n_key,
label = n_key),
vjust = 2, color = "white")
Beachte, dass geom_bar() die Fälle normalerweise zählt (wie bei Barplot 1). Wenn wir das nicht möchten, müssen wir dies mit stat = "identity" angeben (wie bei Barplot 2). Eine ausführliche Erklärung dazu findest du hier.
Für Liniendiagramme gibt es schliesslich die Funktion geom_line().
# Lineplot: Horror- und Liebesfilme im Zeitverlauf
moviedb_line <- moviedb %>%
select(title_year, genres) %>%
mutate(romance = ifelse(str_detect("Romance", genres), 1, 0)) %>%
mutate(horror = ifelse(str_detect("Horror", genres), 1, 0)) %>%
filter(!is.na(title_year)) %>%
group_by(title_year) %>%
summarise_if(is.numeric, funs(sum)) %>%
gather(key = "genre", value = "n_movies", -title_year)
ggplot(data = moviedb_line) +
geom_line(mapping = aes(x = title_year,
y = n_movies,
color = genre))
Eine Übersicht über alle geom-Funktionen bietet das praktische Data Visualization Cheat Sheet. Oft hilft aber auch (bzw. nur) Google.
facenumber_in_poster) in einem Scatterplot. Füge dann mittels geom_smooth() die (geglätteten) Erwartungswerte hinzu. Was passiert, wenn du die method = "auto" wählst? Und was macht das se-Argument?str_detect().# 1. Scatterplot
ggplot(data = moviedb) +
geom_point(mapping = aes(x = facenumber_in_poster, y = imdb_score)) +
geom_smooth(mapping = aes(x = facenumber_in_poster, y = imdb_score),
method = "auto", se = TRUE, level = 0.95)
# gam = generalized additive model
# se = Konfidenzintervall
# 2. Barplot
moviedb_bar <- moviedb %>%
select(actor_1_name, actor_2_name, actor_3_name, genres) %>%
gather(key = "act_nr", value = "actor_name", -genres) %>%
filter(!is.na(actor_name)) %>%
filter(str_detect("Romance", genres)) %>%
group_by(actor_name) %>%
summarise(app = n()) %>%
arrange(desc(app)) %>%
slice(1:10) %>%
mutate(order = as.numeric(rownames(.)))
ggplot(data = moviedb_bar) +
geom_bar(mapping = aes(x = reorder(actor_name, order),
y = app),
stat = "identity") +
theme(axis.text.x = element_text(angle = 45, hjust = 1, vjust = 1),
axis.title.x = element_blank())
# 3. Boxplot
moviedb_cr <- moviedb %>%
filter(content_rating %in% c("G", "PG", "PG-13", "R", "NC-17")) %>%
mutate(order = case_when(content_rating == "G" ~ 1,
content_rating == "PG" ~ 2,
content_rating == "PG-13" ~ 3,
content_rating == "R" ~ 4,
content_rating == "NC-17" ~ 5))
ggplot(data = moviedb_cr) +
geom_boxplot(mapping = aes(x = reorder(content_rating, order),
y = imdb_score))
Mit ggplot2 sind der Kreativität keine Grenzen gesetzt: mit genügend Ausdauer kann an einem Plot alles nach Belieben verändert werden!
Beginnen wir mit der Achsenbeschriftung und dem Titel
# Subset von moviedb erzeugen
moviedb_lang <- moviedb %>%
filter(language %in% c("German", "French", "Japanese", "Hindi"))
# Scatterplot: Dauer und IMDB-Score
plt_1 <- ggplot(data = moviedb_lang) +
geom_point(mapping = aes(x = duration,
y = imdb_score)) +
geom_smooth(mapping = aes(x = duration,
y = imdb_score),
method = "lm", se = TRUE, level = 0.95)
plt_1
# Scatterplot mit angepasster Beschriftung
plt_2 <- plt_1 +
labs(title = "Beziehung zwischen der Länge eines Films und seinem IMDB-Score",
y = "IMDB-Score", x = "Länge in Minuten")
plt_2
Wenn wir jetzt nicht alle Ausreisser sehen möchten, können wir die Achsen anpassen.
# indem wir Ausreisser löschen
plt_3a <- plt_2 +
ylim(c(4, 9)) +
xlim(c(50, 210))
plt_3a
# oder hineinzoomen
plt_3b <- plt_2 +
coord_cartesian(ylim = c(4, 9), xlim = c(50, 210))
plt_3b
# auch die Skalierung der Achsen und ihre Beschriftung kann angepasst werden
plt_3b +
scale_x_continuous(breaks = c(60, 90, 120, 150, 180, 210))
# oder noch elaborierter
plt_3b +
scale_x_continuous(breaks = c(60, 90, 120, 150, 180, 210),
labels = paste0(seq(1,3.5, 0.5), "h"))
Das sieht ja schon nicht schlecht aus. Jetzt können wir dem auch noch etwas Farbe geben.
# zum Beispiel mit roten, grossen Punkten und einer grünen Linie
plt_4a <- ggplot(data = moviedb_lang) +
geom_point(mapping = aes(x = duration,
y = imdb_score),
col = "red", size = 4) +
geom_smooth(mapping = aes(x = duration,
y = imdb_score),
method = "lm", se = TRUE, level = 0.95,
col = "green") +
labs(title = "Beziehung zwischen der Länge eines Films und seinem IMDB-Score",
y = "IMDB-Score", x = "Länge in Minuten") +
coord_cartesian(ylim = c(4, 9), xlim = c(50, 210)) +
scale_x_continuous(breaks = c(60, 90, 120, 150, 180, 210),
labels = paste0(seq(1,3.5, 0.5), "h"))
plt_4a
# oder nach Kategorien
plt_4b <- ggplot(data = moviedb_lang) +
geom_point(mapping = aes(x = duration,
y = imdb_score,
col = language)) +
geom_smooth(mapping = aes(x = duration,
y = imdb_score),
method = "lm", se = TRUE, level = 0.95) +
labs(title = "Beziehung zwischen der Länge eines Films und seinem IMDB-Score",
y = "IMDB-Score", x = "Länge in Minuten") +
coord_cartesian(ylim = c(4, 9), xlim = c(50, 210)) +
scale_x_continuous(breaks = c(60, 90, 120, 150, 180, 210),
labels = paste0(seq(1,3.5, 0.5), "h"))
plt_4b
# auch hier lassen sich die Farben wählen
# Zum Beispiel mit einer Brewer-Palette
plt_4b +
scale_colour_brewer(palette = "Set1")
# Für mehr Brewer-Farben siehe http://colorbrewer2.org/
# oder für Paletten direkt in R
library(RColorBrewer)
brewer.pal.info
Nun können wir noch die Legende, die Beschriftung und den Hintergrund anpassen.
# Titel der Legende anpassen
ggplot(data = moviedb_lang) +
geom_point(mapping = aes(x = duration,
y = imdb_score,
col = language)) +
geom_smooth(mapping = aes(x = duration,
y = imdb_score),
method = "lm", se = TRUE, level = 0.95) +
labs(title = "Beziehung zwischen der Länge eines Films und seinem IMDB-Score",
y = "IMDB-Score", x = "Länge in Minuten", color = "Sprache") +
coord_cartesian(ylim = c(4, 9), xlim = c(50, 210)) +
scale_x_continuous(breaks = c(60, 90, 120, 150, 180, 210),
labels = paste0(seq(1,3.5, 0.5), "h")) +
scale_colour_brewer(palette = "Set1")
# Labels in Legende anpassen
plt_5 <- ggplot(data = moviedb_lang) +
geom_point(mapping = aes(x = duration,
y = imdb_score,
col = language)) +
geom_smooth(mapping = aes(x = duration,
y = imdb_score),
method = "lm", se = TRUE, level = 0.95) +
labs(title = "Beziehung zwischen der Länge eines Films und seinem IMDB-Score",
y = "IMDB-Score", x = "Länge in Minuten", color = "Sprache") +
coord_cartesian(ylim = c(4, 9), xlim = c(50, 210)) +
scale_x_continuous(breaks = c(60, 90, 120, 150, 180, 210),
labels = paste0(seq(1,3.5, 0.5), "h")) +
scale_colour_brewer(palette = "Set1",
labels = c("Französisch", "Deutsch", "Hindi", "Japanisch"))
plt_5
# Legende anders positionieren
plt_5 +
theme(legend.position = "bottom")
# Beschriftung formatieren
plt_5 +
theme(axis.text.x = element_text(size = 12),
axis.text.y = element_text(size = 12),
axis.title.x = element_text(size = 10, face = "bold"),
axis.title.y = element_text(size = 10, face = "bold"),
legend.title = element_text(size = 10, face = "bold"),
legend.text = element_text(size = 10),
plot.title = element_text(face = "bold"))
# Hintergrund und Linien anpassen
plt_6 <- plt_5 +
theme(axis.text.x = element_text(size = 12),
axis.text.y = element_text(size = 12),
axis.title = element_text(size = 10, face = "bold"),
legend.title = element_text(size = 10, face = "bold"),
legend.text = element_text(size = 10),
plot.title = element_text(face = "bold"),
panel.background = element_blank(),
panel.grid.major = element_line(colour = "darkgray", size = 0.5),
panel.grid.minor = element_line(colour = "darkgray", size = 0.1))
plt_6
Anstatt das ganze Aussehen eines Plots händisch zu verändern, können auch Themes verwendet werden.
plt_5 +
theme_dark()
Weitere ggplot2-Möglichkeiten (inkl. zahlreiche Beispiele) finden sich beispielsweise in Chang (2019) oder in diesem sehr guten Tutorial.
In ggplot2 lassen sich Plots mit der praktischen Funktion ggsave() einfach und in beliebigen Formaten speichern.
# Plot als PNG exportieren
ggsave(plt_6, file = "plots/scatterplot_duration-score.png", device = "png")
# oder als PDF
ggsave(plt_6, file = "plots/scatterplot_duration-score.pdf", device = "pdf")
moviedb-Daten einen Plot deiner Wahl und individualisiere ihn dann (Achsenbeschriftung, Titel, Farben, Hintergrund, etc.). Kopiere den Code danach in den Code-Pad, damit alle dein Meisterwerk bewundern können.Action|Adventure|Thriller), wurde nur das erste berücksichtigt (in dem Fall also Action).theme_minimal verwendet.# 2. Barplot
ranks <- moviedb %>%
select(actor_1_name, actor_2_name, actor_3_name) %>%
gather(key = "act_nr", value = "actor_name") %>%
filter(!is.na(actor_name)) %>%
group_by(actor_name) %>%
summarise(app = n()) %>%
arrange(desc(app)) %>%
slice(1:20) %>%
mutate(rank = as.numeric(rownames(.))) %>%
select(-app)
moviedb_bar <- moviedb %>%
select(actor_1_name, actor_2_name, actor_3_name, genres) %>%
gather(key = "act_nr", value = "actor_name", -genres) %>%
filter(!is.na(actor_name)) %>%
separate(genres, into = "genre", sep = "\\|", extra = "drop") %>%
group_by(actor_name, genre) %>%
summarise(app = n()) %>%
ungroup() %>%
spread(genre, app) %>%
mutate(total_app = rowSums(select_if(., is.numeric), na.rm = TRUE)) %>%
arrange(desc(total_app)) %>%
slice(1:20) %>%
select(-total_app) %>%
gather(key = "genre", value = "app", -actor_name) %>%
filter(!is.na(app)) %>%
left_join(ranks, by = "actor_name")
ggplot(data = moviedb_bar) +
geom_bar(mapping = aes(x = reorder(actor_name, rank),
y = app,
fill = genre),
stat = "identity") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1, vjust = 1),
axis.title.x = element_blank()) +
labs(y = "Anzahl Filme", fill = "Genre")
Gratulation, du beherrst die Grundlagen von R! Die R-Welt steht dir damit offen. Geniesse den Moment und freue dich auf grossartige Web-Scraping- und Text-Mining-Projekte mit R. It’s magic! 🌈🦄
Daten können auf ganz unterschiedliche Weise aus dem Web gezogen werden.
Wir werden uns im Folgenden auf das Scraping von statischen, (mehr oder weniger) gut strukturierten Webseiten konzentrieren.
Web Scraping ist eine verlockende, aber auch etwas dunkle Kunst. Nur weil wir etwas können, sollten wir es nicht unbedingt auch tun. Insbesondere bei personenbezogenen Daten (z.B. Rangliste Grand Prix von Bern) und Social-Media-Daten (z.B. Posts von Facebook-Freund_innen) ist höchste Vorsicht und Rücksicht geboten. Kritisch sind aber auch urheberrechtlich geschützte Daten (z.B. Bilder, Musik) sowie Daten, die durch die “Terms of Services” (TOS) eines Anbieters geschützt sind (vgl. Freelon, 2018).
Viele Betreiber*innen von Webseiten möchten zudem nicht, dass automatisiert Daten von ihrer Webseite gezogen werden. Entweder aus Performance-Gründen (Überlastung des Servers) oder weil es ihr Geschäftsmodell untergräbt (Werbung, Quoten).
Auf der sicheren Seite und gute Bürger*innen sind wir, wenn wir uns an folgende Grundsätze halten:
robots.txt beachten (Robots Exclusion Standard), z.B. unibe.ch/robots.txtWeb Scraping umfasst zwei wesentliche Schritte: (1) dem Download von Webseiten und (2) dem Herausdestillieren von (Text-)Daten aus diesen (dem sogenannten Parsing). Herausfordernd ist dabei nicht der Download, sondern das Parsing.
In der Regel handelt es sich bei Webseiten um hierarchisch strukturierte Textdateien, die sowohl von Menschen als auch von Maschinen lesbar sind. Geschrieben sind Webseiten meistens in HTML (Hypertext Markup Language) oder XML (Extensible Markup Language). Dabei handelt es sich um sogenannte Auszeichnungssprachen, die Eigenschaften, Zugehörigkeit und Darstellung von Text (und Daten) mittels Tags definieren.
Tags umschliessen Inhalte nach dem Muster <tag> und </tag>. Durch die Verschachtelung von Tags entsteht eine klare hierarchische Struktur im Dokument. Eine Webseite kann man sich daher als ein mehr oder weniger stark verästelter Baum vorstellen.
Meistens interessieren wir uns aber nicht für einzelne Äste einer Webseite, sondern für bestimmte Inhalte (z.B. Überschriften, Tabellen, Nutztext, Links), die an unterschiedlichen Stellen im Baum vorkommen können. Diese müssen wir beim Parsing mit Hilfe von R aus HTML/XML-Dokumenten extrahieren. Dabei hilft uns, dass gleich aussehende Inhalte (z.B. Überschriften) meistens zur gleichen CSS-Klasse (Cascading Style Sheets) gehören (mit Hilfe von CSS können Inhalte in Klassen zusammengefasst werden, für die sich dann Erscheinungsmerkmale definieren lassen, z.B. Schriftgrösse, Schriftart, Position). Anstatt für jeden gewünschten Inhalt einzeln durch den gesamten Baum zu navigieren, können wir sie über ihre CSS-Klasse direkt und alle auf einmal extrahieren.
Beim Web Scraping brauchen wir R also für folgende Arbeitsschritte:
Bei allen fünf Schritten hilft uns das sehr praktische tidyverse-Package rvest.
# Package installieren
install.packages("rvest")
# und gleich laden
library(rvest)
Möchten wir uns zum Beispiel die letzten Medienmitteilungen der Uni Bern analysieren, können wir sie uns wie folgt beschaffen.
# 1. Herunterladen der Webseite aus dem Web
# 2. Aufgliedern des Dokuments in seine Baumstruktur (in einem R-Objekt)
mm_unibe <- read_html("https://www.unibe.ch/aktuell/medien/media_relations/medienmitteilungen/2020/index_ger.html")
Bei der Navigation mittels CCS helfen uns die Funktionen html_nodes() und xml_nodes(). Mit ihnen können wir den Inhalt einer geladenen Webseite nach einer bestimmten CSS-Klassen filtern.
Die richtige Klasse finden wir, indem wir den Seitenquelltext anschauen oder das super praktische Tool SelectorGadget verwenden.
SelectorGadget ist ein Tool, mit dem wir interkativ die CSS-Klasse der Elemente identifizieren können, die wir extrahieren möchten. Siehe hier für eine ausführlichere Anleitung von SelectorGadget und hier für eine etwas ausführlichere Einführung in CSS.
Die eigentliche Information können wir dann mit html_text() (Text), html_name() (Name des Tags), html_attr() (Attribut, z.B. Hyperlink) extrahieren. Ein Spezialfall sind Tabellen. Sie können mit html_table() direkt ausgelesen werden.
# 3. In der Baumstruktur (mit Hilfe von CSS) zum gewünschten Inhalt navigieren
# 4. Inhalte als R-Daten-Objekt extrahieren
# Datum der Medienmitteilung
datum <- mm_unibe %>%
html_nodes(".title-main") %>%
html_text()
# Titel
titel <- mm_unibe %>%
html_nodes(".title-sub") %>%
html_text()
# Lead
lead <- mm_unibe %>%
html_nodes(".noborderbottom p") %>%
html_text()
# Link zur ganzen Mitteilung
link <- mm_unibe %>%
html_nodes(".title a") %>%
html_attr("href")
# 5. R-Daten-Objekt für Analyse aufbereiten
mm_unibe_df <- data.frame(datum = datum,
titel = titel,
lead = lead,
link = link,
stringsAsFactors = FALSE)
glimpse(mm_unibe_df)
rvest-Funktion html_table(). Der Output ist eine Liste mit Data Frames. Jeder Data Frame (bzw. jedes Listenelemten) ist eine Tabellen der eingelesenen Webseite (das können viele sein!). Zur Erinnerung: Elemente können Listem mit doppelten eckigen Klammern [[]] entnommmen werden.paste oder paste0() einen Satz formulieren, z.B. “D’Aare isch ize 12 Grad warm.”if-else-Anweisung, um die Antwort von deinem aare.guru zu steuern. Ist der aktuelle Abfluss grösser als 360 \(m^3 / s\) soll dir dein aare.guru von einem Schwumm abraten. Ist die Abflussmenge jedoch kleiner, steht einem Schwumm nichts im Weg.paste() formulierte Guru-Antwort ein.# 1. Wassertemperatur
aare_html <- read_html("https://www.hydrodaten.admin.ch/de/2135.html")
aare_tables <- html_table(aare_html)
aare_messw <- aare_tables[[1]]
temp <- aare_messw[1, 4]
paste0("D'Aare isch ize ", temp, " Grad warm.")
# 2. Abflussmenge und Gefahrenstufe
abfl <- aare_messw[1, 2]
if (abfl < 360) {
paste0("D'Aare isch ize ", temp, " Grad warm. D'Wassermängi isch gmüetlech (", abfl, " Kubikmeter pro Sekunde).")
} else {
paste("D'Aare isch ize ", temp, "Grad warm. D'Wassermängi isch gförchig (", abfl, " Kubikmeter pro Sekunde). Das isch z'viu füre Schwumm.")
}
# 3 Lufttemperatur
temp_html <- read_html("https://www.bodenmessnetz.ch/messwerte/aktuelle_daten/zollikofen")
temp_tables <- html_table(temp_html, fill = TRUE)
temp_luft_df <- temp_tables[[2]]
temp_luft <- temp_luft_df[2, 2]
if (abfl < 360) {
paste0("D'Aare isch ize ", temp, " Grad warm. Dusse isches ", temp_luft, " Grad.",
" D'Wassermängi isch gmüetlech (", abfl, " Kubikmeter pro Sekunde).")
} else {
paste0("D'Aare isch ize ", temp, " Grad warm. Dusse isches ", temp_luft, " Grad.",
" D'Wassermängi isch gförchig (", abfl, " Kubikmeter pro Sekunde). Das isch z'viu füre Schwumm.")
}
Das Herausdestillieren der gewünschten Information aus einer Webseite ist oft tricky. In vielen Fällen kommt man nur mit der CSS-Klasse alleine nicht zum Ziel, da die gewünschte Information (z.B. Datum, Zeit, Name) Teil einer längeren Zeichenkette, einem String, ist.
Möchten wir von der Startseite von Wikipedia beispielsweise auslesen, wer kürzlich an welchem Datum und in welchem Alter verstorben ist, geht das nicht mir der CSS-Klasse. Wir müssen die Informationen (Name, Alter, Grund für Bekanntheit, Todesdatum) den Strings entnehmen, die wir mittels CSS-Klase erhalten.
wiki_html <- read_html("https://de.wikipedia.org/wiki/Wikipedia:Hauptseite")
wiki_nec <- wiki_html %>%
html_nodes("#mf-nec li") %>%
html_text()
wiki_nec
Zentrales Werkzeug dabei sind sogenannte Regular Expressions, auch Regex genannt. Mit den richtigen Regex, den passenden Funktionen und genügend Geduld lassen sich fast alle Informationen aus Strings extrahieren.
Regex ist eine Sprache mit der sich syntaktische Muster beschreiben lassen. Mit Hilfe von Funktionen aus dem stringr-Package (tidyverse) können wir anschliessend in Strings nach diesen Mustern suchen.
x <- c("apfel", "birne", "banane", "melone")
# Exakter Match
str_view(x, "an")
# Ein Punkt . ist ein Platzhalter für ein beliebiges Zeichen
# (ausser einer neuen Zeile)
str_view(x, ".n.")
Beachte: wenn wir nach einen Punkt . suchen wollen, müssen wir in der Regex festhalten, dass der Punkt . als Zeichen und nicht als Platzhalter gelesen werden soll. Dafür brauchen wir ein Escape-Zeichen vor dem Punkt. In dem Fall ist das ein linksseitiger Schrägstrich \. Um also nach einem Punkt . zu suchen, müssen wir die Regex \. verwenden.
Leider brauchen wir den Backslash \ in R aber schon als Escape-Zeichen für bestimmte Symbole in Strings (z.B. "\n" für eine neue Zeile oder "\u00b5" für das Mikro-Zeichen \(µ\)). Um nach einem Punkt . zu suchen, brauchen wir in R deshalb zwei Schrägstrichte vor dem Punkt: \\.
x <- c("abc", "a.b", "cb\\")
# Suche nach einem Punkt .
str_view(x, "\\.")
# Linksseitiger Schrägstrich \ (Backslash)
str_view(x, "\\\\")
Standardmässig finden Regex die Muster in allen Teilen eines Strings. Oft steht ein Muster aber am Anfang oder Ende eines Strings. In dem Fall müssen wir die Regex verankern.
^: Verankerung am Anfang$: Verankerung am Endex <- c("apfel", "roter apfel", "apfel gross", "roter apfel gross")
# ohne Verankerung
str_view(x, "apfel")
# Verankerung am Anfang
str_view(x, "^apfel")
# Verankerung am Ende
str_view(x, "apfel$")
# Verankerung am Anfang und am Ende
str_view(x, "^apfel$")
Anstelle des Punkts . können für bestimmte Zeichen auch andere Platzhalter verwendet werden.
\d: Platzhalter für eine Zahl\s: Platzhalter für ein Leerzeichen (Leerschlag, Tab, neue Zeile)[abc]: Platzhalter für a, b oder c[^abc]: Platzhalter für alles ausser a, b, c(ab|cd): Platzhalter für ab oder cdFriendly reminder: Für \d und \s braucht es in R einen zweiten Schrägstrich, also \\d oder \\s.
Eckige Klammern [] können auch eine gute Alternative zum Schrägstriche-Escape von Sonderzeichen sein, also beispielsweise [.] anstatt \\.. Das funktioniert für $, ., |, ?, *, +, (, ), [ und {, nicht aber für ], \, ^ und -.
x <- c("aepfel", "äpfel", "3 aepfel", "gr. aepfel")
# Platzhalter für Zahl: \d
str_view(x, "\\d")
# Platzhalter für Leerzeichen: \s
str_view(x, "\\s")
# Eckige Klammern
str_view(x, "gr[.]")
# Runde Klammer
str_view(x, "(ae|ä)pfel")
Weiter gibt es in Regex die Möglichkeit zu kontrollieren, wie viel Mal ein Zeichen wiederholt wird.
?: 0 oder 1+: 1 oder mehr*: 0 oder mehrAlternativ können die Anzahl Wiederholungen auch genau definiert werden.
{n}: genau n{n,}: n oder mehr{,m}: maximal m{n,m}: zwischen n und mx <- "In römischen Zahlen ist 1888 das längste Jahr: MDCCCLXXXVIII"
# Wiederholungen
str_view(x, "CC?")
str_view(x, "CC+")
str_view(x, "C{2}")
str_view(x, "C{2,}")
Mit runden Klammern () lassen sich Gruppen bilden. Die Zeichen innerhalb einer Gruppe sind nummeriert, sie müssen also exakt in dieser Reihenfolge vorkommen, damit es einen “Match” gibt. Gruppen lassen sich mit Rückbezügen (“backreferences”) wiederholen, zum Beispiel \1 oder \2.
# Alle Früchte, die ein sich wiederholendes, aber beliebiges Buchstabenpaar enthalten
str_view(fruit, "(..)\\1", match = TRUE)
Beim Web Scraping schliesslich besonders wichtig sind sogenannte Lookarounds, da sie uns helfen den Beginn und das Ende eines Musters festzulegen (wenn diese nicht am Zeilenanfang oder -ende stehen). Mit Lookarounds lassen sich nach bestimmten Mustern suchen, die wir als Start- oder Endpunkt von dem festlegen möchten, was uns eigentlich interessiert. Wir können also zum Beispiel nach Informationen suchen, die in einer Klammer stehen.
Unterschieden werden positive und negative Lookarounds. Einen negativen Lookahead brauchen wir, wenn wir nach einem Muster suchen, das nicht von einem bestimmten anderen gefolgt wird. Möchten wir zum Beispiel ein q auf das kein ufolgt, können wir schreiben q(?!u). Möchten wir hingenen ein q auf das ein u folgt, brauchen wir einen positiven Loookahead: q(?=u).
Mit negativen/positiven Lookbehinds funktioniert das auch in die andere Richtung. Möchten wir ein b, vor dem kein a steht, können wir das so formulieren: (?<!a)b (negativer Lookbehind). Soll hingegen ein a vor dem b stehen, können wir den positiven Lookbehind (?<=a)b verwenden.
# negativer Lookahead
str_view(fruit, "e(?!i)", match = TRUE)
# positiver Lookahead
str_view(fruit, "e(?=i)", match = TRUE)
# negativer Lookbehind
str_view(fruit, "(?<!q)u", match = TRUE)
# positiver Lookbehind
str_view(fruit, "(?<=q)u", match = TRUE)
Das Herstellen von richtigen Regex kann sehr nervenaufreibend sein. Hilfreich sind unter anderem das Cheat Sheet und die Website des stringr-Package sowie die sehr gute Website Regular-Expression.info.
Ausserdem gibt es Packages, wie beispielsweise RVerbalExpressions, die einem beim Herstellen von Regex helfen können.
# Package installieren
install.packages("RVerbalExpressions")
# und laden
library(RVerbalExpressions)
# aepfel und äpfel
x <- c("aepfel", "äpfel", "3 aepfel", "gr. aepfel")
regx <- rx() %>%
rx_either_of("ae", "ä") %>% # (ae|ä)
rx_find(value = "pfel") # genauer Match von pfel
regx
str_view(x, regx)
regx <- rx() %>%
rx_seek_prefix(" ") %>% # positiver Lookbehind für Leerschlag
rx_anything() # beliebig viele beliebige Zeichen bis Zeilenende
regx
str_view(x, regx)
.\- suchen? Tipp: Als String muss .\- in R als ".\\-" eingegeben werden, also z.B. must <- c(".\\-")\..\..\..? Wie müsstest du das in R schreiben?stringr::words alle Wörter, die
ed enden, aber nicht mit eed.ing oder ise enden.Tipp: Verwende in str_view() die Option match = TRUE um nur jene Strings anzuzeigen, die dem Kriterium entsprechen.
# 1. Sequenz `\-
must <- c(".\\-")
str_view(must, "\\.\\\\-")
# 2. Regex \..\..\..
# Muster: z.B. ".a.b.c" oder ".3.4.5"
# Schreibweise in R: "\\..\\..\\.."
x <- c("a.b.c", ".d.e.f", "1.2.3.4.5")
str_view(x, "\\..\\..\\..")
# 3. Korpus
str_view(stringr::words, "^y", match = TRUE) # mit "y" beginnen
str_view(stringr::words, "x$", match = TRUE) # mit "x" enden
str_view(stringr::words, "^.{3}$", match = TRUE) # genau drei Buchstaben
str_view(stringr::words, ".{7,}", match = TRUE) # sieben oder mehr Buchstaben
str_view(stringr::words, "^[aeiou]", match = TRUE) # mit einem Vokal beginnen
str_view(stringr::words, "[^e]ed$", match = TRUE) # mit ed, aber nicht eed enden
str_view(stringr::words, "i(ng|se)$", match = TRUE) # auf ing oder ise enden
str_view(stringr::words, "[aeiou]{3,}", match = TRUE) # auf ing oder ise enden
Für die Arbeit mit Strings und Regex können wir Funktionen aus dem stringr-Package verwenden. Das hilfreiche Cheat Sheet findest du hier. Für uns wichtige stringr-Funktion sind:
str_detect(): prüfen, ob ein bestimmtes Muster in einem String vorkommt (gibt TRUE/FALSE zurück)str_extract() und str_extract_all(): ein bestimmtes Muster aus einem String extrahieren (einmal/alle)str_subset(): gibt aus einem Vektor nur jene Strings zurück, die ein bestimmtes Muster enthaltenstr_trim(): Links und/oder rechts Leerzeichen von einem String abschneidenstr_replace() und str_replace_all(): In einem String ein bestimmtes Muster durch ein anderes ersetztenstr_to_lower(): alle Buchstaben eines Strings zu Kleinbuchstaben machenstr_split() und str_split_fixed(): Ein String bei einem bestimmten Muster in Teile zerschneidenstr_c(): stringr-Alternative zu paste()Mit diesen Funktionen und den passenden Regex können wir nun die gewünschen Informationen aus den Wikipedia-Strings extrahieren.
library(rvest)
library(RVerbalExpressions)
wiki_html <- read_html("https://de.wikipedia.org/wiki/Wikipedia:Hauptseite")
wiki_nec <- wiki_html %>%
html_nodes("#mf-nec li") %>%
html_text()
wiki_nec
# Namen
# Hinweis: "lazy" matcht den kürzest möglichen String, "greedy" den längsten (default)
name_rx <- rx() %>%
rx_start_of_line() %>%
rx_anything(mode = "lazy") %>%
rx_seek_suffix(" (")
name <- str_extract(wiki_nec, name_rx)
# Alter
age_rx <- rx() %>%
rx_seek_prefix("(") %>%
rx_digit() %>%
rx_multiple(min = 0, max = 2) %>%
rx_seek_suffix(")")
age <- str_extract(wiki_nec, age_rx)
# Grund für Bekanntheit
prom_rx <- rx() %>%
rx_seek_prefix("), ") %>%
rx_anything(mode = "lazy") %>%
rx_seek_suffix(" (")
prom <- str_extract(wiki_nec, prom_rx)
# Todesdatum
date_rx <- rx() %>%
rx_digit() %>%
rx_multiple(min = 0, max = 2) %>%
rx_find(value = ".") %>%
rx_whitespace() %>%
rx_word()
date <- wiki_nec %>%
str_extract(date_rx) %>%
str_c("2020", sep = " ")
wiki_nec_df <- data.frame(date = date,
age = as.numeric(age),
name = name,
prom = prom,
stringsAsFactors = FALSE)
# 1. Vorstossdaten laden
# Webseite mit Vorstössen laden
vrst_html <- read_html("https://www.bern.ch/politik-und-verwaltung/stadtrat/ratsversand/eingereichte-vorstoesse-vom-27.02.2020")
# Tabelle extrahieren
vrst_df <- html_table(vrst_html)[[1]]
# Spaltennamen anpassen
names(vrst_df) <- c("geschaeft", "geschaeftstitel", "status")
# 2. Infos extrahieren
# 2.1 Titel
## Regex herstellen
titel_rx <- rx() %>%
rx_seek_prefix("):") %>%
rx_anything() %>%
rx_end_of_line()
## Variable mit Vorstosstitel erstellen
vrst_df_1 <- vrst_df %>%
mutate(titel = str_extract(geschaeftstitel, titel_rx))
# 2.2 Instrument
## Motion, Postulat, Interpellation, Kleine Anfrage, Parlamentarische Initiative, Planungserklärung
## siehe: https://www.bern.ch/politik-und-verwaltung/stadtrat/parlamentsbetrieb/parl_aufgaben/instrumente
vrst_df_2 <- vrst_df_1 %>%
mutate(instrument = case_when(str_detect(geschaeftstitel, "Motion") ~ "Motion",
str_detect(geschaeftstitel, "Postulat") ~ "Postulat",
str_detect(geschaeftstitel, "Interpellation") ~ "Interpellation",
str_detect(geschaeftstitel, "Kleine Anfrage") ~ "Kleine Anfrage",
str_detect(geschaeftstitel, "Parlamentarische Initiative") ~ "Parlamentarische Initiative",
str_detect(geschaeftstitel, "Planungserklärung") ~ "Planungserklärung"))
# 2.3 Dringlichkeit
## Dringlich (1) / Nicht dringlich (2)
vrst_df_3 <- vrst_df_2 %>%
mutate(dringlichkeit = ifelse(str_detect(geschaeftstitel, "^Dringlich"), 1, 0))
# 2.4 Vorstoss von
## Interfraktionell (2) / Fraktion (1) / Einzelperson(en) (0)
vrst_df_4 <- vrst_df_3 %>%
mutate(fraktion = case_when(str_detect(geschaeftstitel, "(I|i)nterfraktionell") ~ 2,
str_detect(geschaeftstitel, "Fraktion") ~ 1,
TRUE ~ 0))
# 2.5 Eingereicht durch
## Über Umweg: Dictionary mit allen Ratsmitgliedern
## Liste mit allen Ratsmitgliedern laden
mitgldr_html <- read_html("https://ris.bern.ch/Mitglieder.aspx")
mitgldr_df <- html_table(mitgldr_html)[[1]]
## Spaltennamen anpassen
names(mitgldr_df) <- c("bild", "vorname", "nachname", "partei", "funktion")
## Benötigte Spalten erstellen und andere löschen
# Vor- und Nachname
# Parteikürzel
partei_kurz_rx <- rx() %>% # Regex für Parteikürzel
rx_seek_prefix("(") %>%
rx_anything(mode = "lazy") %>%
rx_seek_suffix(")")
mitgldr_df <- mitgldr_df %>% # Variablen erstellen
mutate(vor_nachname = str_c(vorname, nachname, sep = " ")) %>%
mutate(partei_kurz = str_extract(partei, partei_kurz_rx)) %>%
select(vor_nachname, partei_kurz)
## Suchstring mit allen Mitgliedern herstellen ("|" bedeutet ODER-Verknüpfung)
mitgldr_chr <- str_c(mitgldr_df$vor_nachname, collapse = "|")
## Namen der beteiligten Ratsmitglieder aus Geschäftstitel auslesen
eingr_drch <- str_extract_all(vrst_df_4$geschaeftstitel, mitgldr_chr, simplify = TRUE)
## Extrahierte Namen in richtiges Format bringen
## Matrix zu Data Frame machen
eingr_drch_df <- as.data.frame(eingr_drch, stringsAsFactors = FALSE)
## Spaltennamen anpassen
names(eingr_drch_df) <- paste("eingr_drch", 1:ncol(eingr_drch_df), sep = "_")
## Leere Zellen durch NAs ersetzten
eingr_drch_df[eingr_drch_df == ""] <- NA
## Geschäftsnummer zuspielen
eingr_drch_df <- bind_cols(select(vrst_df_4, geschaeft), eingr_drch_df)
## Parteikürzel zuspielen
eingr_drch_df <- eingr_drch_df %>%
gather(key = "eingr_drch", value = "mitglied", -geschaeft) %>%
select(-eingr_drch) %>%
filter(!is.na(mitglied)) %>%
left_join(mitgldr_df, by = c("mitglied" = "vor_nachname"))
## Extrahierte Namen und Parteikürzel dem ursprünglichen Datensatz zuspielen
## und diesen noch ein bisschen aufräumen
vrst_df_5 <- vrst_df_4 %>%
right_join(eingr_drch_df, by = "geschaeft") %>%
select(-geschaeftstitel, partei = partei_kurz)
# 3. Mögliche Auswertungen
## Vorstösse nach Instrument
d <- vrst_df_5 %>%
group_by(instrument) %>%
summarise(sum = n_distinct(geschaeft)) %>%
arrange(desc(sum))
p <- ggplot(data = d) +
geom_bar(mapping = aes(x = instrument, y = sum),
stat = "identity") +
coord_flip() +
theme_minimal() +
theme(axis.title = element_blank())
p
## Dringlich vs. nicht dringlich
d <- vrst_df_5 %>%
group_by(dringlichkeit) %>%
summarise(sum = n_distinct(geschaeft)) %>%
arrange(desc(sum))
p <- ggplot(data = d) +
geom_bar(mapping = aes(x = as.factor(dringlichkeit), y = sum),
stat = "identity") +
coord_flip() +
theme_minimal() +
theme(axis.title = element_blank())
p
## Vorstösse pro Person
d <- vrst_df_5 %>%
group_by(mitglied, partei) %>%
summarise(sum = n()) %>%
arrange(sum) %>%
mutate(pers = paste0(mitglied, " (", partei, ")"))
p <- ggplot(data = d) +
geom_bar(mapping = aes(x = reorder(pers, sum), y = sum),
stat = "identity") +
coord_flip() +
theme_minimal() +
theme(axis.title = element_blank())
p
## Vorstösse pro Partei
d <- vrst_df_5 %>%
group_by(partei) %>%
summarise(sum = n_distinct(geschaeft)) %>%
arrange(sum)
p <- ggplot(data = d) +
geom_bar(mapping = aes(x = reorder(partei, sum), y = sum),
stat = "identity") +
coord_flip() +
theme_minimal() +
theme(axis.title = element_blank()) +
scale_y_continuous(breaks = 0:10)
p
## Netzwerkanalyse (Advanced): Welche Ratsmitglieder arbeiten zusammen?
el <- select(vrst_df_5, geschaeft, mitglied)
mat_2 <- as.matrix(table(el$mitglied, el$geschaeft))
mat_1 <- mat_2 %*% t(mat_2)
mat_1[upper.tri(mat_1, diag = TRUE)] <- 0
library(igraph)
g <- graph_from_adjacency_matrix(mat_1, mode = "undirected")
plot(g)
## Netzwerkanalyse (Advanced): Welche Parteien arbeiten zusammen?
el <- vrst_df_5 %>%
group_by(geschaeft, partei) %>%
summarise(pers_n = n())
mat_2 <- as.matrix(table(el$partei, el$geschaeft))
mat_1 <- mat_2 %*% t(mat_2)
mat_1[upper.tri(mat_1, diag = TRUE)] <- 0
g <- graph_from_adjacency_matrix(mat_1, mode = "undirected")
plot(g)
# 4. Andere Vorstösse
# Webseite mit Vorstössen laden
vrst_html <- read_html("https://www.bern.ch/politik-und-verwaltung/stadtrat/ratsversand/eingereichte-vorstoesse-vom-28.03.2019")
# Rest genau gleich wie oben ;-)
Text Mining (oder automatisierte Inhaltsanalyse) ist ein Überbegriff für verschiedene Analyseverfahren von Texten. In der Regel sind diese Texte nicht oder nur schwach strukturiert. Das Ziel von Text-Mining-Verfahren ist das Entdecken und Beschreiben von bestimmten Bedeutungsstrukturen in diesen Texten.
Text-Mining-Verfahren lassen sich in drei grobe Kategorien einteilen: Wörterbuch-basierte, überwachte und nicht-überwachte Verfahren (vgl. Boumans & Trilling, 2016). Siehe auch Grimmer und Steward (2013) für eine gute Übersicht.
Wir konzentrieren uns in dieser Einführung auf die Anwendung ausgewählter Zähl- und Wörterbuchverfahren. Dabei werden wir den Tidy-Text-Ansatz von Silge und Robinson (2020) verwendet. Dieser basiert auf den Grundsätzen des Tidyverse, was in von anderen Text-Mining-Ansätzen unterscheidet. Für uns macht das die Arbeit einfach(er). Die meisten Beispiele stammen aus Wiedemann und Niekler (2019) sowie Silge und Robinson (2020), wo es auch noch mehr davon gibt.
Die wichtigsten Funktionen finden sich im tidyverse und im tidytext-Package.
install.packages("tidytext")
library(tidytext)
Da wir im Folgenden viel mit Strings arbeiten werden, legen wir zudem am besten gleich fest, dass wir Strings nie als Faktoren behandeln möchten.
options(stringsAsFactors = FALSE)
Wir werden uns im Folgenden einen Korpus mit Nachrichtmeldungen etwas genauer anschauen. Die im Korpus enthaltenen Meldungen stammen vom SRF-News-Ticker und wurden im April 2020 publiziert. Der Data Frame srf_news enthält nebst einer ID, dem Titel der Meldungen und der Meldung selbst weitere Informationen: das Publikationsdatum, der Publikationszeitpunkt sowie das thematische Ressort.
# Für korrekte Darstellung der Zeiten
install.packages("chron")
library(chron)
# Daten laden
load("data/srf_news.RData")
# Daten anschauen
glimpse(srf_news)
# Zufällige Meldung anschauen
sample_n(srf_news, size = 1)
# 1. Anzahl Meldungen
n_distinct(srf_news$id)
# 2. Zeitraum
range(srf_news$datum)
# oder für erster Tag
srf_news %>%
select(datum) %>%
arrange(datum) %>%
slice(1)
# und letzter Tag
srf_news %>%
select(datum) %>%
arrange(desc(datum)) %>%
slice(1)
# 3. Ressorts
levels(srf_news$ressort)
# oder wie bei Frage 4 für Lösung mit tidyverse
# 4. Meldungen pro Ressort
srf_news %>%
group_by(ressort) %>%
summarise(n = n())
# 5. Anzahl Meldungen pro Tag (Durchschnitt)
srf_news %>%
group_by(datum) %>%
summarise(n = n()) %>%
ungroup() %>%
summarise(mean(n))
# 6. Plots
# 6.1 Meldungen pro Tag
ggplot(data = srf_news) +
geom_bar(mapping = aes(x = datum))
# 6.2 Meldungen pro Ressort
ggplot(data = srf_news) +
geom_bar(mapping = aes(x = ressort))
# 6.3 Meldungen pro Ressort und Tag
ggplot(data = srf_news) +
geom_bar(mapping = aes(x = datum, fill = ressort),
position = "dodge") +
scale_fill_brewer(palette = "Set1")
# 6.4 Meldungen pro Stunde im Tagesverlauf
ggplot(data = srf_news) +
geom_histogram(mapping = aes(x = zeit),
binwidth = 1/24) +
scale_x_continuous(breaks = seq(0, 1, 1 / 6),
labels = paste0(seq(0, 24, 4), ":00"))
Um den Titel und den Text der Meldungen zusammen analysieren zu können, verknüpfen wir die beiden Strings in der bestehenden Spalte text.
# Verknüpfen von titel und text in text
srf_news <- srf_news %>%
mutate(text = str_c(titel, text, sep = " -- "))
Im Moment sind alle Meldungen als lange Strings im Data Frame gespeichert. Viele Text-Mining-Verfahren verstehen Dokumente aber als “Bag of words”, also als Sack mit verschiedenen Wörtern. Wie diese Wörter im Text angeordnet sind (also der Syntax eines Dokuments) spielt bei diesen Verfahren keine Rolle. Wichtig ist nur die Information, ob ein Wort keinmal, einmal, zweimal, dreimal, usw. in einem Dokument vorkommt. Verstehen wir Dokumente als “Bag of words” verlieren wir sehr viel Information (z.B. die genaue Argumentstruktur), können dafür aber auch in grossen Mengen Text relativ schnell bestimmte Bedeutungsstrukturen erkennen.
Um Dokumente als “Bag of words” analysieren zu können, müssen wir sie vorbereiten. Dies geschieht im sogenannten Preprocessing. Dieses umfasst insbesondere das Zerlegen der Dokumente in einzelne Wörter, der sogenannten Tokenization. In Ausnahmefällen kann ein Token auch aus mehreren Wörtern, einem Satz, einem Absatz, einem Kapitel, einem Dokument oder einer anderen grösseren Einheit bestehen.
Mit dem tidytext-Package können Strings mit der Funktion unnest_tokens() in Tokens zerlegt werden. Gleichzeitig können wir mit der Option to_lower = TRUE einen zweiten wichtigen Preprocessing-Schritt machen: das Umwandeln aller Buchstaben im Korpus in Kleinbuchstaben. Das ist sinnvoll, da es in der Regel keine Rolle spielt, ob ein Wort am Satzanfang oder in der Satzmitte steht. Auch dadurch verlieren wir wieder etwas Information (z.B. ob es sich um ein Verb oder Nomen handelt). Gleichzeitig gewinnen wir aber auch, da wir nun in der Lage sind gleiche Wörter als solche zu erkennen. Mit strip_punt = TRUE lassen sich in diesem Schritt ausserdem auch gleich alle Satzzeichen entfernen.
In einem tidy tidytext-Data-Frame haben wir dann ein Token (Wort) pro Dokument pro Zeile.
# Tokenization | lowercase reduction
srf_news_tok <- unnest_tokens(srf_news, input = text, output = word,
token = "words", to_lower = TRUE, strip_punct = TRUE)
head(srf_news_tok, 30)
srf_words <- srf_news_tok %>%
count(word, sort = TRUE)
# Anzahl verschiedene Wörter (= Vokabular)
n_distinct(srf_words$word)
# Die 30 häufigsten Wörter
head(srf_words, 30)
Da sich die Wörter nicht mit einem aktuellen Thema in Verbedingung bringen lassen, ist die Liste inhaltlich leider ziemlich nichtssagend. Aber weshalb ist das so?
Wir kommen dem Problem auf die Spur, wenn wir uns die Häufigkeitsverteilung etwas genauer anschauen. Plotten wir den Rang eines Wortes (gemessen an dessen Häufigkeit) auf der X-Achse und die Häufigkeit des Wortes auf der Y-Achse, bekommen wir folgendes Bild.
srf_words_rank <- srf_news_tok %>%
count(word, sort = TRUE) %>%
mutate(rank = row_number())
# Verteilung als Plot
ggplot(data = srf_words_rank) +
geom_line(mapping = aes(x = rank, y = n)) +
labs(x = "Rang", y = "Häufigkeit")
Wir sehen, dass es wenige Wörter gibt, die sehr häufig vorkommen und viele Wörter, die nur selten vorkommen. Diese Verteilung ist typisch für Sprachdaten. Sie kann in den meisten Sprachen so beobachtet werden und hat sogar einen eigenen Namen: Zipf-Verteilung. Der Verteilung liegt das Zipfsche Gesetz zugrunde, das vereinfacht besagt, dass die Häufigkeit eines Wortes umgekehrt proportional zu seinem Rang ist.
Um den Plot etwas lesbarer zu machen, können wir die Achsen logarithmieren. Eine genau umgekehrt proportionale Beziehung hätte bei dieser Darstellung eine konstante, negative Steigung.
ggplot(data = srf_words_rank) +
geom_line(mapping = aes(x = rank, y = n)) +
scale_x_log10() +
scale_y_log10() +
labs(x = "Log-Rang", y = "Log-Häufigkeit")
Wörter mit Rang 1000 und höher kommen nur etwa 10-mal oder noch weniger in unserem Korpus vor, Wörter mit Rang 10 und tiefer dafür mehr als 1000-mal.
Das Ziel von Text Mining ist es, automatisch Bedeutungsstrukturen in Korpora zu finden. Dabei sind Wörter, die häufig und in fast allen Dokumenten vorkommen keine grosse Hilfe. Sie tragen kaum etwas zum Inhalt eines Textes bei. Ignorieren wir sie, haben wir zwei entscheidende Vorteile: (1) wir haben ein kleineres Vokabular (d.h. wir brauchen weniger Speicherplatz und müssen weniger lang rechnen), (2) wir können Bedeutungsstrukturen einfacher erkennen.
Eine Möglichkeit besonders häufige Wörter zu entfernen, sind sogenannte Stoppwortlisten. Diese enthalten Wörter, die oft verwendet werden, aber wenig zur semantischen Bedeutung von Texten beitragen. Online finden sich zahlreiche Stoppwortlisten, die sich zum Beispiel als CSV in R importiert lassen. Noch einfacher geht es aber mit den im tidytext-Package inkludierten Stoppwortlisten.
# Deutsche Stoppwörter
get_stopwords(language = "de")
# Verschiedene deutsche Stoppwortlisten
stopwords::stopwords_getsources()
get_stopwords(language = "de", source = "stopwords-iso")
Um die Stoppwörter aus unserem Korpus zu entfernen, können wir die tidyverse-Funktion anti_join() verwenden. Damit entfernen wir alle Wörter, die sowohl im linken als auch im rechten Dokument vorkommen.
# Stoppwörter entfernen
srf_news_tok_ns <- srf_news_tok %>%
anti_join(get_stopwords(language = "de", source = "stopwords-iso"), by = "word")
Für bestimmte Analysen macht es Sinn, wenn auch besonders seltene Wörter ausgeschlossen werden. In der Regel helfen uns seltene Wörter kaum, wenn wir nach grösseren Bedeuttungsstrukturen suchen. Die Idee hinter solchen Strukturen (z.B. Themen) ist ja genau, dass sie in verschiedenen Dokumenten vorkommen. Wir könnten die seltenen Wörter direkt dem oben erstellen Data Frame srf_words_rank entnehmen und sie dann im Korpus löschen. Da wir uns aber vorerst nur für die besonders häufigen Wörter interessieren und unser Korpus ausserdem sehr heterogen ist (d.h. Dokumente zu unterschiedlichen Themen umfasst), entfernen wir die seltenen Wörter vorerst nicht.
Aber wie sieht das Bild nun ohne die Stoppwörter aus? Um es besser beurteilen zu können, legen wir die alte und die neue Verteilung übereinander.
srf_words_rank_ns <- srf_words_rank %>%
mutate(ns = ifelse(word %in% srf_news_tok_ns$word, 1, 0))
# Übrig gebliebene Wörter sind grün, gelöschte schwarz
ggplot() +
geom_line(data = srf_words_rank_ns, mapping = aes(x = rank, y = n)) +
geom_point(data = subset(srf_words_rank_ns, ns == 1),
mapping = aes(x = rank, y = n), col = "green", size = 2) +
scale_x_log10() +
scale_y_log10() +
labs(x = "Log-Rang", y = "Log-Häufigkeit")
Welche Wörter sind nun die häufigsten?
srf_words_ns <- srf_news_tok_ns %>%
count(word, sort = TRUE)
# Die 30 häufigsten Wörter
head(srf_words_ns, 30)
Das sieht schon besser aus! Unschön sind allerdings noch die vereinzelten Zahlen. Diese lassen sich ohne Kontext nicht sinnvoll interpretieren. Wir entfernen sie deshalb mit Hilfe einer Regex-Filterung.
# Zahlen entfernen
srf_news_prep <- srf_news_tok_ns %>%
filter(!str_detect(word, "(^\\d*$)|(^\\d*(')\\d*$)|(^\\d*(.)\\d*$)|(^\\d*(,)\\d*$)"))
# Fertig vorbereiteter Korpus speichern
save(srf_news_prep, file = "data/srf_news_prep.RData")
Ein weiterer möglicher Preprocessing-Schritt ist das Stemming. Dabei werden Wortendungen von flektierten Wörtern abgeschnitten (z.B. “Wortes” und “Worten” zu “Wort”). In R ist das mit dem SnowballC-Package für verschiedene Sprachen einfach möglich. Ziel des Stemmings ist es, dass Wöter mit der gleichen Bedeutung in der Analyse auch als solche erkannt und behandelt werden. Da aber die Interpretation von Wortstämmen teilweise etwas schwierig ist (v.a. wenn man die Texte im Korpus nicht kennt), verzichten wir vorerst darauf.
Abschliessende Warnung: Beim Preprocessing kommt es auf die Reihenfolge der verschiedenen Bereinigungsschritte an (Denny & Spirling, 2018). Stemming würde man typischerweise vor dem Ausschluss besonders seltener Wörter in den Prozess einbauen, da Wörter dadurch “häufiger” werden.
# 1. Die 20 häufiste Wörter im Korpus
srf_words <- srf_news_prep %>%
count(word, sort = TRUE) %>%
slice(1:20)
ggplot(data = srf_words) +
geom_bar(mapping = aes(x = reorder(word, n), y = n),
stat = "identity") +
labs(x = "Wort", y = "Anzahl") +
coord_flip()
# 2. Die 20 häufiste Wörter pro Ressort
# Variante 1: tidyverse
# Daten vorbereiten
srf_words_res <- srf_news_prep %>%
group_by(ressort) %>%
count(word, sort = TRUE) %>%
slice(1:20) %>%
ungroup() %>%
arrange(ressort, n) %>%
mutate(rank = row_number())
# Plot erstellen
ggplot(data = srf_words_res) +
geom_bar(mapping = aes(x = rank, y = n),
stat = "identity") +
facet_wrap(~ ressort, scales = "free") +
theme(axis.title = element_blank()) +
scale_x_continuous(
breaks = srf_words_res$rank,
labels = srf_words_res$word,
expand = c(0,0)) +
coord_flip()
# Variante 2: gridExtra
srf_words_res <- srf_news_prep %>%
group_by(ressort) %>%
count(word, sort = TRUE) %>%
slice(1:20)
# Plot für jedes Ressort erstellen
res <- unique(srf_words_res$ressort)
p <- list()
for (i in 1:length(res)) {
p[[i]] <- ggplot(data = filter(srf_words_res, ressort == res[i])) +
geom_bar(mapping = aes(x = reorder(word, n), y = n),
stat = "identity") +
labs(title = paste("Ressort:", res[i])) +
theme(axis.title = element_blank()) +
coord_flip()
}
p[[1]]
p[[2]]
# gridExtra: Alle Plots in einer Grafik kombinieren
install.packages("gridExtra")
library(gridExtra)
grid.arrange(p[[1]], p[[2]], p[[3]], p[[4]], p[[5]], ncol = 2)
# 3. Mögliche Stoppwörter
# ...
# ...
# ...
# 4. Wörter, die rausgefallen sind
n_orig <- n_distinct(srf_news_tok$word)
n_prep <- n_distinct(srf_news_prep$word)
# So viele sind rausgefallen
n_orig - n_prep
(n_orig - n_prep) / n_orig
# So viele sind übrig geblieben
n_prep
n_prep / n_orig
# 5. Rausgefallene Dokumente
id_orig <- n_distinct(srf_news_tok$id)
id_prep <- n_distinct(srf_news_prep$id)
id_orig == id_prep
Die häufigsten Wörter in einem Korpus zu ermitteln, ist bereits eine (einfache) Häufigkeitsanalyse. Oft interessieren wir uns aber nicht für die häufigsten Wörter, sondern für ein paar wenige, sorgfältig ausgewählte Terme (z.B. Schlüsselwörter, die für ein bestimmtes Thema stehen). Aufschlussreich sind dabei Zeitreihenanalysen, bei denen untersucht wird, wie häufig ein bestimmtes Wort (oder bestimmte Wörter) zu einem bestimmten Zeitpunkt vorkommt und wie sich diese Zahl über die Zeit entwickelt.
Möchten wir zum Beispiel herausfinden, wie häufig bestimmte Länder in den Meldungen von SRF im Zeitverlauf genannt werden, können wir das in einem Liniendiagramm darstellen.
# Länder festlegen
terms_to_observe <- c("schweiz", "deutschland", "italien", "usa")
# Nach Länder filtern und auszählen
srf_term_freq <- srf_news_prep %>%
filter(word %in% terms_to_observe) %>%
group_by(datum) %>%
count(word)
# Liniendiagramm erstellen
ggplot(data = srf_term_freq) +
geom_line(mapping = aes(x = datum, y = n, col = word),
stat = "identity")
Die Darstellung von mehreren Zeitreihen in einem Plot kann schnell unübersichtlich werden. Heatmaps sind deshalb eine gute Alternative. In Heatmaps lassen sich auch viele Zeitreihen relativ problemlos darstellen. Bei dieser Methode werden die verschiedenen Zeitreihen in einer Matrix übereinander (also in Zeilen) dargestellt. Jede Spalte entspricht dabei ein Zeitpunkt (bzw. Zeitraum). Jede Zelle enthält den Häufigkeitswert, den ein bestimmtes Wort zu einem bestimmten Zeitpunkt/-raum hat.
Zeitreihen lassen sich in Heatmaps ausserdem nach Ähnlichkeit sortieren. Dadurch lassen sich Wörter mit ähnlichen Häufigkeitsmustern besser erkennen als in einem Liniendiagramm. Die Ähnlichkeit zweier Verläufe kann zudem mittels Baumdiagramm angezeigt werden.
# Suchbegriffe festlegen
terms_to_observe <- c("berset", "merkel", "johnson", "trump", "bolsonaro", "conte", "leyen",
"koch", "salathé", "althaus", "drosten", "fauci", "tegnell")
# Korpus nach Suchbegriffen filtern
srf_term_freq <- srf_news_prep %>%
filter(word %in% terms_to_observe)
# Häufigkeitsmatrix herstellen
srf_term_freq_mat <- as.matrix(table(srf_term_freq$datum, srf_term_freq$word))
srf_term_freq_mat
# Heatmap plotten
heatmap(t(srf_term_freq_mat), Colv = NA, keep.dendro = FALSE,
col = rev(heat.colors(256)), scale = "none", margins = c(7, 3))
terms_to_observe aus und stelle sie in einer Heatmap dar. Kopiere deinen Code anschliessend in den Code Dump auf Ilias.# 1. Kurven für ausländische Politiker*innen
terms_to_observe <- c("trump", "putin", "merkel", "macron", "orban", "kurz")
srf_term_freq <- srf_news_prep %>%
filter(word %in% terms_to_observe) %>%
group_by(datum) %>%
count(word)
ggplot(data = srf_term_freq) +
geom_line(mapping = aes(x = datum, y = n, col = word),
stat = "identity")
# 2. Sportmeldungen
terms_to_observe <- c("fussball", "eishockey", "tennis")
srf_term_freq <- srf_news_prep %>%
filter(word %in% terms_to_observe) %>%
group_by(id, datum) %>%
summarise(word = first(word)) %>%
group_by(datum) %>%
count(word)
ggplot(data = srf_term_freq) +
geom_line(mapping = aes(x = datum, y = n, col = word),
stat = "identity") +
theme_minimal() +
labs(y = "Anzahl Dokumente", x = "Datum")
Worthäufigkeiten lassen sich nicht nur im Zeitverlauf aggregieren, sondern auch in Kategorien. Wenn wir beispielsweise nach positiven und negativen Wörtern im Korpus suchen, können wir eine einfache Sentimentanalyse machen.
Sentimentanalysen haben zum Ziel, die Stimmung (Polarität) eines Textes oder Tokens zu quantifizieren. Im Wesentlichen wird analysiert, ob ein Text mehr positive (z.B. “Glück”) oder negativ Wörter (z.B. “Verrat”) enthält. Dabei kann jedes Wort auf einer Skala von -1 (extrem negativ) bis +1 (extrem positiv) eingeordnet werden. Die Arbeit mit einem Spektrum ist sinnvoll, da “perfekt” beispielsweise wesentlich positiver wahrgenommen wird als “zufrieden”, wobei jedoch beide positiv sind.
Eine deutschsprachige Liste mit positiven und negativen Wörtern wurde von Remus, Quasthoff und Heyer (2010) im SentiWS-Paket zusammengestellt. Um mit den Listen arbeiten zu können, müssen wir sie nur laden und in die für uns richtige Form bringen.
# Tabulator-getrennte TXT-Files einlesen
sentiws_neg <- read_tsv(file = "data/SentiWS_v2.0_Negative.txt", col_names = FALSE)
sentiws_pos <- read_tsv(file = "data/SentiWS_v2.0_Positive.txt", col_names = FALSE)
head(sentiws_neg)
head(sentiws_pos)
names(sentiws_neg) <- c("word", "polarity", "flex_form")
names(sentiws_pos) <- c("word", "polarity", "flex_form")
# SentiWS-Listen umformen
# Wortart-Zusatz (|NN, etc.) von Stammform enfernen
# Flektierte Formen auftrennen
# Stammform und flektierte Formen in eine Spalte (word) nehmen
# NA und leere Zeilen löschen
# Alle Wörter klein schreiben
# Duplikate löschen (entstanden durch Kleinschreibung)
# Benötigte Spalten auswählen (polarity, word)
# Nach Wörter sortieren
sentiws_neg_prep <- sentiws_neg %>%
mutate(word = str_remove(word, "\\|.*$")) %>%
separate(flex_form, into = paste0("flex_form_", 1:30), sep = ",") %>%
gather(key = "col_name", value = "word", -polarity) %>%
filter(!is.na(word), word != "") %>%
mutate(word = str_to_lower(word)) %>%
distinct(word, .keep_all = TRUE) %>%
select(polarity, word) %>%
arrange()
sentiws_pos_prep <- sentiws_pos %>%
mutate(word = str_remove(word, "\\|.*$")) %>%
separate(flex_form, into = paste0("flex_form_", 1:30), sep = ",") %>%
gather(key = "col_name", value = "word", -polarity) %>%
filter(!is.na(word), word != "") %>%
mutate(word = str_to_lower(word)) %>%
distinct(word, .keep_all = TRUE) %>%
select(polarity, word) %>%
arrange()
head(sentiws_neg_prep, 30)
head(sentiws_pos_prep, 30)
Einige Wörter stehen sowohl auf der positiven als auch auf der negativen Liste. “Sorgen” ist als Nomen beispielsweise negativ, als Verb “sorgen” aber positiv. Da wir aber nur noch klein geschriebene Wörter haben, können wir die Wortart nicht mehr feststellen. Wir entfernen diese Wörtern deshalb aus unserer Liste.
# Positive und negative Liste zusammenspielen
sentiws <- bind_rows(sentiws_neg_prep, sentiws_pos_prep)
# Duplikate entfernen
sentiws <- sentiws[!(duplicated(sentiws$word) | duplicated(sentiws$word, fromLast = TRUE)), ]
Nun sind wir bereit und können die Polaritätswerte mit einem left_join() unserem tidy SRF-Korpus zuspielen. Gleichzeitig definieren wir alle Wörter ohne Polaritätswert als 0. Das ist nötig, da nicht alle Wörter als positiv oder negativ bewertet wurden. Um die Polarität einer Meldung nicht zu überschätzen, definieren wir die nicht klassifizierten Wörter entsprechend als neutral (0).
# SentiWS dem SRF-Korpus zuspielen
srf_news_senti <- srf_news_prep %>%
left_join(sentiws, by = "word") %>%
mutate(polarity = ifelse(is.na(polarity), 0, polarity))
Jetzt können wir auswerten! Zum Beispiel können wir das durchschnittliche Sentiment der Meldungen pro Ressort berechnen.
# Durchschnittliches Sentiment pro Meldung pro Ressort
srf_senti_res <- srf_news_senti %>%
group_by(id, ressort) %>%
summarise(pol_doc = mean(polarity)) %>%
group_by(ressort) %>%
summarise(pol_res = mean(pol_doc))
ggplot(data = srf_senti_res) +
geom_bar(mapping = aes(x = ressort, y = pol_res),
stat = "identity") +
scale_y_continuous(limits = c(-0.05, 0.05)) +
geom_hline(yintercept = 0)
Ziemlich traugig: Nachrichten scheinen meist negativ zu sein. Doch gibt es auch positive Nachrichten? Was sind zum Beispiel die fünf positivsten in unserem Korpus?
# Die fünf positivsten Meldungen
srf_senti_pos <- srf_news_senti %>%
group_by(id) %>%
summarise(pol_doc = mean(polarity)) %>%
arrange(desc(pol_doc)) %>%
slice(1:5) %>%
left_join(select(srf_news, id, ressort, titel, text), by = "id")
View(srf_senti_pos)
# ... und die fünf negativsten
srf_senti_neg <- srf_news_senti %>%
group_by(id) %>%
summarise(pol_doc = mean(polarity)) %>%
arrange(pol_doc) %>%
slice(1:5) %>%
left_join(select(srf_news, id, ressort, titel, text), by = "id")
View(srf_senti_neg)
Wir können die Polaritätswerte der Wörter beliebig aggregieren. So können wir uns zum Beispiel auch die Veränderung über die Zeit anschauen. Wie entwickelt sich das Sentiment der Meldungen an einem durchschnittlichen Tag? Gibt es am Vormittag oder Nachmittag mehr schlechte Nachrichten?
# Sentiment im Tagesverlauf
srf_senti_tag <- srf_news_senti %>%
mutate(stunde = hours(zeit)) %>%
group_by(id, stunde) %>%
summarise(pol_doc = mean(polarity)) %>%
group_by(stunde) %>%
summarise(pol_stund = mean(pol_doc))
ggplot(data = srf_senti_tag) +
geom_bar(mapping = aes(x = stunde, y = pol_stund),
stat = "identity") +
scale_y_continuous(limits = c(-0.05, 0.05)) +
geom_hline(yintercept = 0)
Schauen wir bei den positiven und negativen Wörtern noch etwas genauer hin: Was sind die 20 häufigsten positiven und negativen Ausdrücke über alle Ressorts und Meldungen hinweg betrachtet?
# Die 20 häufigsten positiven und negativen Wörter
srf_senti_words <- srf_news_senti %>%
mutate(dir = case_when(polarity < 0 ~ "neg",
polarity > 0 ~ "pos")) %>%
filter(!is.na(dir)) %>%
group_by(dir, polarity, word) %>%
count(word) %>%
group_by(dir) %>%
arrange(desc(n)) %>%
slice(1:20) %>%
mutate(label = paste0(word, " (", polarity, ")"))
ggplot(data = srf_senti_words) +
geom_bar(mapping = aes(x = reorder(label, n), y = n),
stat = "identity") +
facet_wrap(~ dir, scales = "free_y") +
theme(axis.title = element_blank()) +
coord_flip()
# 1. Häufigste positive/negative Wörter pro Ressort
srf_senti_words <- srf_news_senti %>%
mutate(dir = case_when(polarity < 0 ~ "neg",
polarity > 0 ~ "pos")) %>%
filter(!is.na(dir)) %>%
group_by(ressort, dir, polarity, word) %>%
count(word) %>%
group_by(ressort, dir) %>%
arrange(desc(n)) %>%
slice(1:20) %>%
mutate(label = paste0(word, " (", polarity, ")"))
res <- unique(srf_senti_words$ressort)
p <- list()
for (i in 1:length(res)) {
p[[i]] <- ggplot(data = filter(srf_senti_words, ressort == res[i])) +
geom_bar(mapping = aes(x = reorder(label, n), y = n),
stat = "identity") +
facet_wrap(~ dir, scales = "free_y") +
labs(title = paste("Ressort:", res[i])) +
theme(axis.title = element_blank()) +
coord_flip()
print(p[[i]])
}
# 2. Positive/negative Wörter in Meldungen
# Anzahl positive und negative Wörter pro Meldung
srf_senti_words_n <- srf_news_senti %>%
mutate(dir = case_when(polarity < 0 ~ "n_neg",
polarity > 0 ~ "n_pos",
polarity == 0 ~ "n_neu")) %>%
group_by(id, dir) %>%
count() %>%
spread(key = dir, value = n)
# Die fünf negativsten Meldungen
srf_senti_neg <- srf_news_senti %>%
group_by(id) %>%
summarise(pol_doc = mean(polarity)) %>%
arrange(pol_doc) %>%
slice(1:5) %>%
left_join(select(srf_news, id, ressort, titel, text), by = "id") %>%
left_join(srf_senti_words_n, by = "id")
View(srf_senti_neg)
# Die fünf positivsten Meldungen
srf_senti_pos <- srf_news_senti %>%
group_by(id) %>%
summarise(pol_doc = mean(polarity)) %>%
arrange(desc(pol_doc)) %>%
slice(1:5) %>%
left_join(select(srf_news, id, ressort, titel, text), by = "id") %>%
left_join(srf_senti_words_n, by = "id")
View(srf_senti_pos)
# 3. Sentiment im Ressort International im Zeitverlauf
srf_senti_int <- srf_news_senti %>%
filter(ressort == "int") %>%
group_by(id, datum) %>%
summarise(pol_doc = mean(polarity)) %>%
group_by(datum) %>%
summarise(pol_day = mean(pol_doc))
ggplot(data = srf_senti_int) +
geom_bar(mapping = aes(x = datum, y = pol_day),
stat = "identity") +
scale_y_continuous(limits = c(-0.05, 0.05)) +
geom_hline(yintercept = 0)
Als nächstes versuchen wir Schlüsselwörter zu identifizieren. Schlüsselwörter eines Dokuments sind diejenigen Wörter, die den Inhalt des Dokuments am besten beschreiben. Um diese zu finden, müssen wir die Wörter hinsichlich ihrer semantischen Relevanz für ein Dokument gewichten können. Eine verbreitetes Gewichtungsmass ist hierbei die term frequency–inverse document frequency, kurz TF-IDF. Die Idee hinter TF-IDF ist, dass je häufiger ein Wort in einem Dokument vorkommt, desto wichtiger ist es für dieses Dokument. Gleichzeitig gilt aber: In je mehr Dokumente ein Wort vorkommt, desto weniger aussagekräftig ist es für ein einzelnes Dokument. Das Produkt aus beiden Messungen ist der resultierende TF-IDF-Wert für ein bestimmtes Wort in einem bestimmten Dokument. Für Wörter, die in sehr vielen Dokumenten eines Korpus vorkommen, geht der TF-IDF-Wert gegen Null.
Im tidytext-Package gibt es dafür die Funktion bind_tf_idf(). Nebst einer Spalte mit dem Wort (in unserem Fall word) und einer mit der Dokument-Information (bei uns id oder titel), braucht die Funktion als Input auch noch eine Spalte, die angibt, wie häufig ein Wort in einem Dokument vorkommt. Um diese letzte Spalte zu erhalten, müssen wir srf_news_prep noch aggregieren.
srf_news_tf_idf <- srf_news_prep %>%
group_by(id) %>%
count(word) %>%
bind_tf_idf(word, id, n)
Nun können wir uns für jedes beliebige Dokument die Schlüsselwörter anschauen, zum Beispiel als Wordcloud.
install.packages("wordcloud")
library(wordcloud)
# ID von Dokument auswählen, z.B. 63, 257, 364, 388, 420, 613
id_to_test <- 63
# Schlüsselwörter anschauen
tf_idf_sel <- srf_news_tf_idf %>%
filter(id == id_to_test) %>%
arrange(desc(tf_idf)) %>%
slice(1:10)
wordcloud(tf_idf_sel$word, tf_idf_sel$tf_idf)
# Dokument anschauen
srf_news %>%
filter(id == id_to_test) %>%
select(text)
Bedenke: Ob ein Wort ein Schlüsselwort ist, ist sowohl vom Dokument als auch vom Korpus (mit dem das Dokument verglichen wird) abhängig. Würden wir die TF-IDF-Gewichte beispielsweise nur für die Meldungen aus dem Schweiz-Ressort berechnen, bekämen wir andere Werte, da die zugrunde liegenden Wortverteilungen andere sind.
Gleiches gilt auch, wenn wir nicht mit den flektierten Wörtern, sondern mit den Wortstämmen arbeiten. Für das Stemming können wir die Funktion wordStem() aus dem SnowballC-Package verwenden.
install.packages("SnowballC")
library(SnowballC)
# TF-IDF-Gewichte für "gestemmten" Korpus berechnen
srf_news_tf_idf_stem <- srf_news_prep %>%
mutate(stem = wordStem(word, language = "german")) %>%
group_by(id) %>%
count(stem) %>%
bind_tf_idf(stem, id, n)
# Schlüsselwörter von ursprünglichem und "gestemmtem" Korpus vergleich
library(gridExtra)
# ID von Dokument auswählen, z.B. 63, 257, 364, 388, 420, 613
id_to_test <- 63
# Schlüsselwörter auswählen
tf_idf_sel <- srf_news_tf_idf %>%
filter(id == id_to_test) %>%
arrange(desc(tf_idf)) %>%
slice(1:10) %>%
mutate(corp = "orig") %>%
select(id, word, tf_idf, corp)
tf_idf_sel_stem <- srf_news_tf_idf_stem %>%
filter(id == id_to_test) %>%
arrange(desc(tf_idf)) %>%
slice(1:10) %>%
mutate(corp = "stem") %>%
select(id, word = stem, tf_idf, corp)
# Plots machen
p_orig <- ggplot(data = tf_idf_sel) +
geom_bar(mapping = aes(x = reorder(word, tf_idf), y = tf_idf),
stat = "identity") +
labs(title = "Original corpus", y = "TF-IDF", x = NULL) +
ylim(0, max(c(tf_idf_sel$tf_idf, tf_idf_sel_stem$tf_idf))) +
coord_flip()
p_stem <- ggplot(data = tf_idf_sel_stem) +
geom_bar(mapping = aes(x = reorder(word, tf_idf), y = tf_idf),
stat = "identity") +
labs(title = "Stemmed corpus", y = "TF-IDF", x = NULL) +
ylim(0, max(c(tf_idf_sel$tf_idf, tf_idf_sel_stem$tf_idf))) +
coord_flip()
grid.arrange(p_orig, p_stem, ncol = 2)
id == 61, wenn die TF-IDF-Gewichte nur mit dem Subkorpus ressort == "ch" (anstatt aller Meldungen) berechnet werden?# 1. Top-20-Schlüsselwörter
srf_news_tf_idf_stem %>%
group_by(id) %>%
arrange(desc(tf_idf)) %>%
slice(1) %>%
group_by(stem) %>%
count(sort = TRUE) %>%
ungroup() %>%
slice(1:20)
# 2. Schlüsselwörter von Dokument 61 im CH-Subkorpus
srf_news_prep %>%
filter(ressort == "ch") %>%
mutate(stem = wordStem(word, language = "german")) %>%
group_by(id) %>%
count(stem) %>%
bind_tf_idf(stem, id, n) %>%
filter(id == 61) %>%
arrange(desc(tf_idf)) %>%
slice(1:10)
# 3. Italien
srf_news_tf_idf_stem %>%
group_by(id) %>%
arrange(desc(tf_idf)) %>%
slice(1:5) %>%
filter(stem == "wuhan") %>%
left_join(srf_news, by = "id") %>%
select(id, n_keyword = n, tf_idf, text)
Als letztes schauen wir uns Kookkurrenzen an. Dabei interessiert uns, welche Wörter besonders oft direkt nacheinander, gemeinsam in einem Satz oder im gleichen Dokument vorkommen. Wir interessieren uns also für die Beziehung zwischen Wörtern. Bisher lag unser primärer Fokus auf der Beziehung zwischen Wörtern und Dokumenten, Ressorts, Sentiments oder der Zeit.
Kookkurenzen sind deshalb interessant, weil ein semantischer oder narrativer Zusammenhang vermutet werden kann, wenn zwei (oder mehr) Wörter häufig zusammen vorkommen. Es kann zum Beispiel sein, dass es sich um den Vor- und Nachname einer Person handelt oder um Wörter, die eine bestimmtes Konzept oder Thema beschreiben (z.B. “erneuerbare Energien”).
Grundsätzlich lassen sich zwei Arten von Kookkurrenzen unterscheiden:
Beschäftigen wir uns zuerst mit Nachbarschaftskookkurrenzen, sogenannten n-grams. Typischerweise werden hierbei Bigrams, also Paare von direkt nebeneinander stehenden Wörtern, angeschaut.
Mit dem tidyverse-Package können wir einfach einen Data Frame mit alle möglichen Bigrams erstellen. Dazu brauchen wir die Optionen token = "ngrams" und n = 2 in der Funktion unnest_tokens().
load("data/srf_news.RData")
# Tokenization als Bigrams
srf_news_bigram <- unnest_tokens(srf_news, input = text, output = bigram,
token = "ngrams", n = 2, to_lower = TRUE)
head(srf_news_bigram, 30)
Ein Token ist nun nicht mehr ein Wort, sondern ein Bigram. Dabei überschneiden sich die Bigrams, z.B. “corona in”, “in den” und “den usa”. Gleich wie oben können wir die Bigrams auszählen.
srf_news_bigram %>%
count(bigram, sort = TRUE)
Es zeigt sich die gleiche Problematik wie oben: Stoppwörter dominieren die Hitiste. Wir müssen sie also entfernen. Gleichzeitig entfernen wir auch wieder alle Wörter, die nur aus Zahlen bestehen.
# Stoppwörter definieren
stopwords <- get_stopwords(language = "de", source = "stopwords-iso")
# Regex für Zahlen definieren
numb <- "(^\\d*$)|(^\\d*(')\\d*$)|(^\\d*(.)\\d*$)|(^\\d*(,)\\d*$)"
# Stoppwörter entfernen
srf_news_bigram_ns <- srf_news_bigram %>%
separate(bigram, c("word_1", "word_2"), sep = " ") %>%
filter(!word_1 %in% stopwords$word) %>%
filter(!word_2 %in% stopwords$word) %>%
filter(!str_detect(word_1, numb)) %>%
filter(!str_detect(word_2, numb)) %>%
mutate(bigram = str_c(word_1, word_2, sep = " "))
# Bigrams ohne Stoppwörter auszählen
srf_news_bigram_count <- srf_news_bigram_ns %>%
count(word_1, word_2, sort = TRUE)
head(srf_news_bigram_count, 30)
Wie bei einzelne Wörtern, können wir auch für Bigrams ihr TF-IDF-Gewicht berechnen, zum Beispiel für jedes Ressort.
srf_news_bigram_tf_idf <- srf_news_bigram_ns %>%
group_by(ressort) %>%
count(bigram) %>%
bind_tf_idf(bigram, ressort, n) %>%
arrange(desc(tf_idf)) %>%
slice(1:20)
res <- unique(srf_news_bigram_tf_idf$ressort)
p <- list()
for (i in 1:length(res)) {
p[[i]] <- ggplot(data = subset(srf_news_bigram_tf_idf, ressort == res[i])) +
geom_bar(mapping = aes(x = reorder(bigram, tf_idf), y = tf_idf),
stat = "identity") +
labs(title = paste("Ressort:", res[i]), y = "TF-IDF", x = NULL) +
ylim(0, max(c(srf_news_bigram_tf_idf$tf_idf, srf_news_bigram_tf_idf$tf_idf))) +
coord_flip()
print(p[[i]])
}
Anstatt nur auf einige besonders häufige Paare zu schauen, können wir uns natürlich auch die Beziehung aller Wörter anschauen. Am einfachsten geht das, wenn wir sie als Netzwerk darstellen. Dabei sind die Wörter die Knoten (nodes) und ihre Beziehung die Kanten (edges). Kommen zwei Wörter im Korpus als Bigram vor, werden sie durch eine Kante miteinander verbunden. Kommen sie mehrfach als Bigram vor, bekommt die Kante das Gewicht + 1 (weight).
Um aus unserem tidy Data Frame ein Netzwerk zu machen, brauchen wir das igraph-Package. Das igraph-Objekt können wir dann mit Funktionen aus dem ggraph-Package visualisieren.
install.packages(c("igraph", "ggraph"))
library(igraph)
library(ggraph)
# Bigrmas im Format: from, to, weight
head(srf_news_bigram_count)
# igraph-Objekt erstellen
srf_news_graph <- srf_news_bigram_count %>%
filter(n > 7) %>%
graph_from_data_frame()
srf_news_graph
# Netzwerk visualisieren
ggraph(srf_news_graph, layout = "nicely") +
geom_edge_link() +
geom_node_point() +
geom_node_text(aes(label = name), vjust = 1, hjust = 1)
# Visualisierung noch etwas informativer machen (Pfeile)
ggraph(srf_news_graph, layout = "nicely") +
geom_edge_link(aes(edge_alpha = n), show.legend = FALSE,
arrow = arrow(length = unit(4, "mm")),
end_cap = circle(2, "mm"), edge_width = 1) +
geom_node_point(size = 5) +
geom_node_text(aes(label = name), repel = TRUE) +
theme_void()
Bei dieser Darstellung handelt es sich um die Visualisierung einer Markov-Kette. Markov-Ketten werden im Text-Mining sehr häufig gebraucht. In einer Markov-Kette hängt jedes Wort von dem unmittelbar davor ab. In unserem (sehr einfachen) Modell (trainiert mit dem SRF-News-Korpus und basierend auf Häufigkeiten) würde ein Wortgenerator nach “daniel” immer “koch” vorschlagen, nach “tour” immer “de” und nach “de” immer “france”. Um das Netzwerk anschauen zu können, haben wir eine Auswahl getroffen (Edge weight > 7). Würden wir alle Paare drin lassen, bekämen wir das gesamte Modell visualisiert (würden aber nichts mehr erkennen).
# 1. Trigrams
srf_news_trigram <- unnest_tokens(srf_news, input = text, output = bigram,
token = "ngrams", n = 3, to_lower = TRUE)
# Stoppwörter definieren
stopwords <- get_stopwords(language = "de", source = "stopwords-iso")
# Regex für Zahlen definieren
numb <- "(^\\d*$)|(^\\d*(')\\d*$)|(^\\d*(.)\\d*$)|(^\\d*(,)\\d*$)"
# Stoppwörter entfernen
srf_news_trigram_ns <- srf_news_trigram %>%
separate(bigram, c("word_1", "word_2", "word_3"), sep = " ") %>%
filter(!word_1 %in% stopwords$word) %>%
filter(!word_2 %in% stopwords$word) %>%
filter(!word_3 %in% stopwords$word) %>%
filter(!str_detect(word_1, numb)) %>%
filter(!str_detect(word_2, numb)) %>%
filter(!str_detect(word_3, numb))
# Bigrams ohne Stoppwörter auszählen
srf_news_trigram_count <- srf_news_trigram_ns %>%
count(word_1, word_2, word_3, sort = TRUE)
head(srf_news_trigram_count, 30)
Beschäftigen wir uns zum Schluss noch mit Satz- bzw. Dokumentkookkurrenzen. Im Unterschied zu Nachbarschaftskookkurrenzen müssen Wörter dabei nicht unbedingt direkt aufeinanderfolgen um als Paar zu zählen. Es reicht, wenn sie beide im gleichen Satz oder Dokument vorkommen. So können wir Wortcluster identifizieren, die zum Beschrieb von Themen/Bedeutungsstrukturen gebraucht werden.
Da es sich bei unseren SRF-Meldungen um relative kurze Dokumente handelt, die zudem immer nur ein Thema behandeln, analysieren wir hier Dokumentkookkurrenzen. Da wir die id jedes Dokuments schon als Metainformation in unserem Data Frame haben, können wir gleich mit dem Preprocessing beginnen.
# Stoppwörter definieren
stopwords <- get_stopwords(language = "de", source = "stopwords-iso")
# Regex für Zahlen definieren
numb <- "(^\\d*$)|(^\\d*(')\\d*$)|(^\\d*(.)\\d*$)|(^\\d*(,)\\d*$)"
# Preprocessing
# Dokumente in Wörter zerlegen (Tokenization)
# Stoppwörter entfernen
# Zahlen löschen
srf_news_prep <- srf_news %>%
unnest_tokens(input = text, output = word, token = "words", to_lower = TRUE) %>%
anti_join(stopwords, by = "word") %>%
filter(!str_detect(word, numb))
head(srf_news_prep, 20)
Die Kookkurrenzen im Tidy-Text-Format zu zählen ist tricky (und rechenintensiv). Am einfachsten geht es mit einem Umweg über eine Matrix (siehe hier für eine ausführlichere Erklärung). Das heisst, dass wir den Data Frame umbauen müssen. Glücklicherweise gibt es eine geeignete Funktion, die das für uns macht: pairwise_count() aus dem widyr-Package.
install.packages("widyr")
library(widyr)
# Dokumentkookkurrenzen zählen
srf_news_word_pairs <- srf_news_prep %>%
pairwise_count(word, id, sort = TRUE)
head(srf_news_word_pairs, 20)
Noch interessanter wird es, wenn wir uns nicht nur die häufigsten Paare anschauen, sondern vergleichen, wie oft zwei Wörter zusammen und wie oft sie getrennt voneinander vorkommen. Dafür berechnen wir, wie stark zwei Wörter (in einem Korpus) miteinander korrelieren. Der entsprechende Korrelationskoeffizient heisst \(ϕ\) (Phi. Dieser gibt an, wie wahrscheinlich es ist, dass zwei Wörter X und Y in einem Dokument/Satz zusammen oder gar nicht vorkommen, verglichen damit, dass einem Dokument/Satz nur X oder Y vorkommt.
Wenn \(n_{11}\) für die Anzahl Dokumente steht, in denen X und Y zusammen vorkommen, \(n_{00}\) für die Dokumente, wo keines der beiden Wörter vorkommt und \(n_{10}\) bzw. \(n_{01}\) für die Anzahl Dokumente, in denen entweder X oder Y vorkommt, berechnet sich \(ϕ\) wie folgt:
Wenn \(ϕ = 1\), dann kommen zwei Wörter ausschliesslich zusammen vor, wenn \(ϕ = 0\) ausschliesslich separat.
Glücklicherweise ist die Formel für die Berechnung für \(ϕ\) ebenfalls im widyr-Package implementiert. Wir können den Koeffizienten mit der Funktion pairwise_cor() schnell und einfach berechnen.
# Korrelation für Dokumentkookkurrenzen
srf_news_word_cor <- srf_news_prep %>%
group_by(word) %>%
filter(n() > 5) %>%
pairwise_cor(word, id, sort = TRUE)
head(srf_news_word_cor, 20)
Die Korrelationen können wir uns schliesslich auch als Netzwerk anschauen. Im folgenden Fall schauen wir uns alle Wortkombinationen mit \(ϕ > 0.7\) an.
# Korrelationsnetzwerk plotten
srf_news_word_cor %>%
filter(correlation > 0.7) %>%
graph_from_data_frame() %>%
ggraph(layout = "nicely") +
geom_edge_link(aes(edge_alpha = correlation), show.legend = FALSE) +
geom_node_point(color = "lightblue", size = 5) +
geom_node_text(aes(label = name), repel = TRUE) +
theme_void()
srf_news %>%
select(-text) %>%
unnest_tokens(input = titel, output = word, token = "words", to_lower = TRUE) %>%
anti_join(stopwords, by = "word") %>%
filter(!str_detect(word, numb)) %>%
group_by(word) %>%
filter(n() >= 2) %>%
pairwise_cor(word, id, sort = TRUE) %>%
filter(correlation > 0.7) %>%
graph_from_data_frame() %>%
ggraph(layout = "nicely") +
geom_edge_link(aes(edge_alpha = correlation), show.legend = FALSE) +
geom_node_point(color = "lightblue", size = 5) +
geom_node_text(aes(label = name), repel = TRUE) +
theme_void()
Gratulation, du beherrschst die Grundlagen des Web Scrapings und Text Minings in R meisterlich! Nun gilt es die gute Form beim Take-home Examen noch einmal zu bestätigen. Viel Erfolg!
Boumans, J. W., & Trilling, D. (2016). Taking stock of the toolkit: An overview of relevant automated content analysis approaches and techniques for digital journalism scholars. Digital Journalism, 4(1), 8–23. https://doi.org/10.1080/21670811.2015.1096598
Chang, W. (2019). R graphics cookbook (2nd edition.). Sebastopol, CA: O’Reilly. https://r-graphics.org/
Denny, M. J., & Spirling, A. (2018). Text preprocessing for unsupervised learing: Why it matters, when it misleads, and what to do about it. Political Analysis, 26(2), 168–189. https://doi.org/https://doi.org/10.1017/pan.2017.44
Freelon, D. (2018). Computational research in the post-API age. Political Communication, 35(4), 665–668. https://doi.org/10.1080/10584609.2018.1477506
Freelon, D., Lopez, L., Clark, M. D., & Jackson, S. J. (2018). How black Twitter and other social media communities interact with mainstream news. Knight Foundation. https://knightfoundation.org/features/twittermedia
Grimmer, J., & Stewart, B. M. (2013). Text as data: The promise and pitfalls of automatic content analysis methods for political texts. Political Analysis, 21(3), 267–297. https://doi.org/10.1093/pan/mps028
Nagler, J. (1995). Coding style and good computing practices. PS: Political Science and Politics, 28(3), 488–492. https://doi.org/10.2307/420315
Remus, R., Quasthoff, U., & Heyer, G. (2010). SentiWS - A publicly available German-language resource for sentiment analysis. In Proceedings of the 7th International Language Ressources and Evaluation (LREC 2010) (S. 1168–1171). Valletta, Malta. http://www.lrec-conf.org/proceedings/lrec2010/pdf/490_Paper.pdf
Silge, J., & Robinson, D. (2020). Text mining with R: A tidy approach. Sebastopol, CA: O’Reilly. https://www.tidytextmining.com/
Wickham, H. (2014). Tidy data. Journal of Statistical Software, 59(10), 1–23. https://doi.org/10.18637/jss.v059.i10
Wickham, H., & Grolemund, G. (2017). R for data science: Import, tidy, transfrom, visualize, and model data. Sebastopol, CA: O’Reilly. https://r4ds.had.co.nz/
Wiedemann, G., & Niekler, A. (2019). Hands-on: a five day text mining course for humanists and social scientists in R. In Proceedings of the 1st Workshop Teaching NLP for Digital Humanities (Teach4DH@GSCL 2017) (S. 57–65). Berlin, Germany. https://tm4ss.github.io/docs/index.html